Skip to content

Commit 72d6640

Browse files
mpywclaude
andcommitted
feat: add -external-spawner flag for external package spawners
Add flag-based spawner specification for external packages, complementing the //goroutinectx:spawner directive for local functions. Changes: - Add -external-spawner flag (comma-separated, pkg.Func or pkg.Type.Method) - Extend spawner.Map to support both directive-based and flag-based spawners - Add matchesSpec for matching external functions by pkg path + name - Update spawner and spawnerlabel checkers to use *Map - Add TestExternalSpawner with workerpool stub Usage: goroutinectx -external-spawner='github.com/example/pool.Pool.Submit' ./... 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5655f4f commit 72d6640

File tree

10 files changed

+278
-23
lines changed

10 files changed

+278
-23
lines changed

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
1111
- **goroutine**: Detect `go func()` that doesn't capture/use context
1212
- **errgroup**: Detect `errgroup.Group.Go()` closures without context
1313
- **waitgroup**: Detect `sync.WaitGroup.Go()` closures without context (Go 1.25+)
14-
- **spawner**: Detect calls to functions marked with `//goroutinectx:spawner` that pass closures without context
14+
- **spawner**: Detect calls to spawner functions that pass closures without context
15+
- Directive: `//goroutinectx:spawner` marks local functions
16+
- Flag: `-external-spawner=pkg/path.Func` or `-external-spawner=pkg/path.Type.Method` for external functions
1517
- **goroutine-derive**: Detect goroutines that don't call a specified context-derivation function (e.g., `apm.NewGoroutineContext`)
1618
- Activated via flag: `-goroutine-deriver=pkg/path.Func` or `-goroutine-deriver=pkg/path.Type.Method`
1719
- OR (comma): `-goroutine-deriver=pkg1.Func1,pkg2.Func2` - at least one must be called

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,24 @@ goroutinectx -context-carriers=github.com/labstack/echo/v4.Context,github.com/ur
303303
304304
When a function has a context carrier parameter, goroutinectx will check that it's properly propagated to goroutines and other APIs.
305305

306+
### `-external-spawner`
307+
308+
Mark external package functions as spawners. This is the flag-based alternative to `//goroutinectx:spawner` directive for functions you don't control.
309+
310+
```bash
311+
# Single external spawner
312+
goroutinectx -external-spawner='github.com/example/workerpool.Pool.Submit' ./...
313+
314+
# Multiple external spawners (comma-separated)
315+
goroutinectx -external-spawner='github.com/example/workerpool.Pool.Submit,github.com/example/workerpool.Run' ./...
316+
```
317+
318+
**Format:**
319+
- `pkg/path.Func` for package-level functions
320+
- `pkg/path.Type.Method` for methods
321+
322+
When an external spawner is called, goroutinectx checks that func arguments properly use context.
323+
306324
### Checker Enable/Disable Flags
307325

308326
Most checkers are enabled by default. Use these flags to enable or disable specific checkers:

analyzer.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
// Flags for the analyzer.
2929
var (
3030
goroutineDeriver string
31+
externalSpawner string
3132
contextCarriers string
3233

3334
// Checker enable/disable flags (all enabled by default).
@@ -42,6 +43,8 @@ var (
4243
func init() {
4344
Analyzer.Flags.StringVar(&goroutineDeriver, "goroutine-deriver", "",
4445
"require goroutines to call this function to derive context (e.g., pkg.Func or pkg.Type.Method)")
46+
Analyzer.Flags.StringVar(&externalSpawner, "external-spawner", "",
47+
"comma-separated list of external spawner functions (e.g., pkg.Func or pkg.Type.Method)")
4548
Analyzer.Flags.StringVar(&contextCarriers, "context-carriers", "",
4649
"comma-separated list of types to treat as context carriers (e.g., github.com/labstack/echo/v4.Context)")
4750

@@ -77,8 +80,8 @@ func run(pass *analysis.Pass) (any, error) {
7780
// Build ignore maps for each file
7881
ignoreMaps := buildIgnoreMaps(pass)
7982

80-
// Build spawner map from //goroutinectx:spawner directives
81-
spawners := spawnerdir.Build(pass)
83+
// Build spawner map from //goroutinectx:spawner directives and -external-spawner flag
84+
spawners := spawnerdir.Build(pass, externalSpawner)
8285

8386
// Run AST-based checks (goroutine, errgroup, waitgroup)
8487
runASTChecks(pass, insp, ignoreMaps, carriers, spawners)
@@ -110,7 +113,7 @@ func runASTChecks(
110113
insp *inspector.Inspector,
111114
ignoreMaps map[string]ignore.Map,
112115
carriers []carrier.Carrier,
113-
spawners spawnerdir.Map,
116+
spawners *spawnerdir.Map,
114117
) {
115118
// Build context scopes for functions with context parameters
116119
funcScopes := buildFuncScopes(pass, insp, carriers)
@@ -138,7 +141,7 @@ func runASTChecks(
138141
}
139142

140143
// Add spawner checker if enabled and any functions are marked
141-
if enableSpawner && len(spawners) > 0 {
144+
if enableSpawner && spawners.Len() > 0 {
142145
callCheckers = append(callCheckers, spawner.New(spawners))
143146
}
144147

analyzer_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,23 @@ func TestSpawner(t *testing.T) {
8686
analysistest.Run(t, testdata, goroutinectx.Analyzer, "spawner")
8787
}
8888

89+
func TestExternalSpawner(t *testing.T) {
90+
testdata := analysistest.TestData()
91+
92+
// Set external spawner flag for workerpool package
93+
externalSpawners := "github.com/example/workerpool.Pool.Submit," +
94+
"github.com/example/workerpool.Run"
95+
if err := goroutinectx.Analyzer.Flags.Set("external-spawner", externalSpawners); err != nil {
96+
t.Fatal(err)
97+
}
98+
99+
defer func() {
100+
_ = goroutinectx.Analyzer.Flags.Set("external-spawner", "")
101+
}()
102+
103+
analysistest.Run(t, testdata, goroutinectx.Analyzer, "externalspawner")
104+
}
105+
89106
func TestSpawnerlabel(t *testing.T) {
90107
testdata := analysistest.TestData()
91108

internal/checkers/spawner/checker.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@ import (
1111

1212
// Checker checks calls to spawner functions.
1313
type Checker struct {
14-
spawners spawner.Map
14+
spawners *spawner.Map
1515
}
1616

1717
// New creates a new spawner checker.
18-
func New(spawners spawner.Map) *Checker {
18+
func New(spawners *spawner.Map) *Checker {
1919
return &Checker{spawners: spawners}
2020
}
2121

2222
// CheckCall implements checkers.CallChecker.
2323
func (c *Checker) CheckCall(cctx *context.CheckContext, call *ast.CallExpr) {
24-
if len(c.spawners) == 0 {
24+
if c.spawners.Len() == 0 {
2525
return
2626
}
2727

internal/checkers/spawnerlabel/checker.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import (
1212

1313
// Checker validates that functions are properly labeled with //goroutinectx:spawner.
1414
type Checker struct {
15-
spawners spawner.Map
15+
spawners *spawner.Map
1616
}
1717

1818
// New creates a new spawnerlabel checker.
19-
func New(spawners spawner.Map) *Checker {
19+
func New(spawners *spawner.Map) *Checker {
2020
return &Checker{spawners: spawners}
2121
}
2222

internal/checkers/spawnerlabel/detector.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ type spawnCallInfo struct {
2727

2828
// isSpawnCall checks if a call expression is a spawn call that takes func arguments.
2929
// Returns the spawn info if it's a spawn call with func arguments, nil otherwise.
30-
func isSpawnCall(pass *analysis.Pass, call *ast.CallExpr, spawners spawner.Map) *spawnCallInfo {
30+
func isSpawnCall(pass *analysis.Pass, call *ast.CallExpr, spawners *spawner.Map) *spawnCallInfo {
3131
// Check known spawn methods first
3232
if info := isKnownSpawnMethod(pass, call); info != nil {
3333
return info
@@ -193,8 +193,8 @@ func isGotaskTaskType(pass *analysis.Pass, expr ast.Expr) bool {
193193
}
194194

195195
// isSpawnerMarkedCall checks if the call is to a spawner-marked function with func args.
196-
func isSpawnerMarkedCall(pass *analysis.Pass, call *ast.CallExpr, spawners spawner.Map) *spawnCallInfo {
197-
if len(spawners) == 0 {
196+
func isSpawnerMarkedCall(pass *analysis.Pass, call *ast.CallExpr, spawners *spawner.Map) *spawnCallInfo {
197+
if spawners.Len() == 0 {
198198
return nil
199199
}
200200

internal/directives/spawner/directive.go

Lines changed: 147 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,175 @@
1-
// Package spawner handles //goroutinectx:spawner directives.
1+
// Package spawner handles //goroutinectx:spawner directives and -external-spawner flag.
22
package spawner
33

44
import (
55
"go/ast"
66
"go/types"
77
"strings"
8+
"unicode"
89

910
"golang.org/x/tools/go/analysis"
1011
)
1112

13+
// FuncSpec holds parsed components of a spawner function specification.
14+
type FuncSpec struct {
15+
PkgPath string
16+
TypeName string // empty for package-level functions
17+
FuncName string
18+
}
19+
1220
// Map tracks functions marked with //goroutinectx:spawner.
1321
// These functions are expected to spawn goroutines with their func arguments.
14-
type Map map[*types.Func]struct{}
22+
type Map struct {
23+
local map[*types.Func]struct{} // from directives
24+
external []FuncSpec // from -external-spawner flag
25+
}
1526

1627
// IsSpawner checks if a function is marked as a spawner.
17-
func (m Map) IsSpawner(fn *types.Func) bool {
18-
_, ok := m[fn]
28+
func (m *Map) IsSpawner(fn *types.Func) bool {
29+
if m == nil {
30+
return false
31+
}
32+
33+
// Check local map first (directive-based)
34+
if _, ok := m.local[fn]; ok {
35+
return true
36+
}
37+
38+
// Check external specs (flag-based)
39+
return m.matchesExternal(fn)
40+
}
41+
42+
// Len returns the total number of spawners (local + external).
43+
func (m *Map) Len() int {
44+
if m == nil {
45+
return 0
46+
}
47+
48+
return len(m.local) + len(m.external)
49+
}
50+
51+
// matchesExternal checks if fn matches any external spec.
52+
func (m *Map) matchesExternal(fn *types.Func) bool {
53+
for _, spec := range m.external {
54+
if matchesSpec(fn, spec) {
55+
return true
56+
}
57+
}
1958

20-
return ok
59+
return false
2160
}
2261

23-
// Build scans files for functions marked with the directive.
24-
func Build(pass *analysis.Pass) Map {
25-
m := make(Map)
62+
// matchesSpec checks if a function matches a FuncSpec.
63+
func matchesSpec(fn *types.Func, spec FuncSpec) bool {
64+
if fn.Name() != spec.FuncName {
65+
return false
66+
}
67+
68+
pkg := fn.Pkg()
69+
if pkg == nil || pkg.Path() != spec.PkgPath {
70+
return false
71+
}
72+
73+
// Check if it's a method
74+
sig := fn.Type().(*types.Signature)
75+
recv := sig.Recv()
76+
77+
if spec.TypeName == "" {
78+
// Package-level function: should have no receiver
79+
return recv == nil
80+
}
81+
82+
// Method: should have receiver of correct type
83+
if recv == nil {
84+
return false
85+
}
86+
87+
recvType := recv.Type()
88+
// Handle pointer receivers
89+
if ptr, ok := recvType.(*types.Pointer); ok {
90+
recvType = ptr.Elem()
91+
}
92+
93+
named, ok := recvType.(*types.Named)
94+
if !ok {
95+
return false
96+
}
97+
98+
return named.Obj().Name() == spec.TypeName
99+
}
100+
101+
// Build scans files for functions marked with the directive
102+
// and parses the external spawner flag.
103+
func Build(pass *analysis.Pass, externalSpawners string) *Map {
104+
m := &Map{
105+
local: make(map[*types.Func]struct{}),
106+
external: parseExternal(externalSpawners),
107+
}
26108

27109
for _, file := range pass.Files {
28-
buildSpawnersForFile(pass, file, m)
110+
buildSpawnersForFile(pass, file, m.local)
29111
}
30112

31113
return m
32114
}
33115

116+
// parseExternal parses the -external-spawner flag value.
117+
// Format: comma-separated list of "pkg/path.Func" or "pkg/path.Type.Method".
118+
func parseExternal(s string) []FuncSpec {
119+
if s == "" {
120+
return nil
121+
}
122+
123+
var specs []FuncSpec
124+
125+
for part := range strings.SplitSeq(s, ",") {
126+
part = strings.TrimSpace(part)
127+
if part == "" {
128+
continue
129+
}
130+
131+
spec := parseFunc(part)
132+
specs = append(specs, spec)
133+
}
134+
135+
return specs
136+
}
137+
138+
// parseFunc parses a single spawner function string into components.
139+
// Format: "pkg/path.Func" or "pkg/path.Type.Method".
140+
func parseFunc(s string) FuncSpec {
141+
spec := FuncSpec{}
142+
143+
lastDot := strings.LastIndex(s, ".")
144+
if lastDot == -1 {
145+
spec.FuncName = s
146+
147+
return spec
148+
}
149+
150+
spec.FuncName = s[lastDot+1:]
151+
prefix := s[:lastDot]
152+
153+
// Check if there's another dot (indicating Type.Method)
154+
// Type names start with uppercase in Go.
155+
secondLastDot := strings.LastIndex(prefix, ".")
156+
if secondLastDot != -1 {
157+
possibleType := prefix[secondLastDot+1:]
158+
if len(possibleType) > 0 && unicode.IsUpper(rune(possibleType[0])) {
159+
spec.TypeName = possibleType
160+
spec.PkgPath = prefix[:secondLastDot]
161+
162+
return spec
163+
}
164+
}
165+
166+
spec.PkgPath = prefix
167+
168+
return spec
169+
}
170+
34171
// buildSpawnersForFile scans a single file for spawner directives.
35-
func buildSpawnersForFile(pass *analysis.Pass, file *ast.File, m Map) {
172+
func buildSpawnersForFile(pass *analysis.Pass, file *ast.File, m map[*types.Func]struct{}) {
36173
// Build a map of line -> comment for quick lookup
37174
lineComments := make(map[int]string)
38175

0 commit comments

Comments
 (0)