Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/linters/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/github/gh-aw/pkg/linters/errormessage"
"github.com/github/gh-aw/pkg/linters/errstringmatch"
"github.com/github/gh-aw/pkg/linters/excessivefuncparams"
"github.com/github/gh-aw/pkg/linters/execcommandwithoutcontext"
"github.com/github/gh-aw/pkg/linters/fileclosenotdeferred"
"github.com/github/gh-aw/pkg/linters/fmterrorfnoverbs"
"github.com/github/gh-aw/pkg/linters/fprintlnsprintf"
Expand Down Expand Up @@ -48,6 +49,7 @@ func main() {
errormessage.Analyzer,
fprintlnsprintf.Analyzer,
errstringmatch.Analyzer,
execcommandwithoutcontext.Analyzer,
excessivefuncparams.Analyzer,
fileclosenotdeferred.Analyzer,
fmterrorfnoverbs.Analyzer,
Expand Down
40 changes: 40 additions & 0 deletions docs/adr/38185-add-execcommandwithoutcontext-linter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# ADR-38185: Enforce context propagation to subprocesses via a dedicated linter

**Date**: 2026-06-09
**Status**: Draft

## Context

The codebase spawns subprocesses with `os/exec`, but does so inconsistently: there are roughly 122 bare `exec.Command(...)` calls versus only 27 `exec.CommandContext(ctx, ...)` calls in production code, and that ratio had not improved since 2026-06-03 (per the deep-report tracked in discussion #38123). Bare `exec.Command` inside a function that already receives a `context.Context` produces a subprocess that cannot be cancelled when the context is cancelled (timeout, shutdown, request abort), leaking work and slowing graceful termination. Issue #38143 explicitly requested a sibling analyzer to the existing `ctxbackground` linter to catch this pattern. The repository already maintains a family of ~22 custom `golang.org/x/tools/go/analysis` analyzers wired into a multichecker in `cmd/linters/main.go`, so there is an established convention for adding type-accurate static checks.

## Decision

We will add a new custom static-analysis linter, `execcommandwithoutcontext`, that flags `exec.Command(...)` calls occurring inside a function that already has a `context.Context` parameter and recommends `exec.CommandContext(ctx, ...)` instead. The analyzer follows the existing linter family conventions: it lives under `pkg/linters/execcommandwithoutcontext/`, uses cursor-based AST traversal with `pass.TypesInfo` for type-accurate matching (resolving the actual `os/exec` and `context` package identities rather than matching on names), reuses the shared `internal/filecheck` helper to skip test files, and is registered in the `cmd/linters/main.go` multichecker. We chose a compile-time linter (rather than runtime enforcement or documentation) so that new un-cancellable subprocess spawns are blocked at CI time before they land.

## Alternatives Considered

### Alternative 1: Extend the existing `ctxbackground` analyzer
Issue #38143 framed this as extending `ctxbackground`. We rejected folding the check into that analyzer because the two checks target distinct call shapes (`context.Background()` misuse vs. `exec.Command` cancellation propagation) with different reporting messages and fixtures. Keeping them as separate single-purpose analyzers matches the existing one-concern-per-linter convention and keeps each analyzer's tests focused.

### Alternative 2: Rely on documentation, code review, or a `golangci-lint` community rule
We could document the convention and enforce it through review, or look for an off-the-shelf rule. This was rejected because review-based enforcement is exactly what allowed the 122-vs-27 imbalance to accumulate, and no existing community linter encodes the "context parameter is in scope" precondition. The repository's established pattern is custom `go/analysis` passes, which give precise, type-aware, CI-enforceable detection.

## Consequences

### Positive
- New `exec.Command` calls in context-receiving functions are caught automatically at CI time, preventing further accumulation of un-cancellable subprocess spawns.
- Immediately surfaces 3 live violations (`pkg/workflow/github_cli.go:32`, `pkg/cli/mcp_inspect_mcp.go:150`, `pkg/cli/mcp_inspect_mcp.go:155`) for follow-up.
- Consistent with the existing analyzer family, so it is low-friction for maintainers to understand and maintain.

### Negative
- Adds another analyzer to the multichecker, marginally increasing lint runtime and the surface area of custom tooling to maintain.
- The check is intentionally narrow: it only fires when a `context.Context` parameter is directly in the enclosing `FuncDecl`. It will not catch `exec.Command` calls that could obtain a context transitively (e.g., from a struct field or a closure capture), so some un-cancellable spawns remain undetected.
- Existing violations are not auto-fixed; they must be remediated separately, and the linter may need temporary suppression or a backlog until they are addressed.

### Neutral
- Blank-identifier context parameters (`_ context.Context`) are deliberately not flagged, since there is no in-scope context value to pass.
- The analyzer reports a diagnostic only; it does not provide an automated suggested fix (no `analysis.SuggestedFix`).

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27227016743) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
5 changes: 5 additions & 0 deletions pkg/linters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This package currently provides custom Go analyzers in the following subpackages
- `errormessage` — reports non-actionable error-message patterns in changed files.
- `errstringmatch` — reports `strings.Contains(err.Error(), "...")` patterns and recommends `errors.Is` / `errors.As`.
- `fileclosenotdeferred` — reports non-deferred file `Close()` calls that can leak resources.
- `execcommandwithoutcontext` — reports `exec.Command(...)` calls inside functions that already receive `context.Context` and should use `exec.CommandContext(...)`.
- `fmterrorfnoverbs` — reports `fmt.Errorf` calls whose format string contains no verbs, recommending `errors.New` instead.
- `fprintlnsprintf` — reports `fmt.Fprintln(..., fmt.Sprintf(...))` patterns and recommends direct formatting calls.
- `jsonmarshalignoredeerror` — reports `json.Marshal` and `json.Unmarshal` calls where the error return is discarded with `_`.
Expand Down Expand Up @@ -42,6 +43,7 @@ This package currently provides custom Go analyzers in the following subpackages
| `excessivefuncparams` | Custom `go/analysis` analyzer that flags function declarations with too many positional parameters |
| `errormessage` | Custom `go/analysis` analyzer that flags non-actionable error message patterns in changed files |
| `errstringmatch` | Custom `go/analysis` analyzer that flags brittle `strings.Contains(err.Error(), "...")` checks |
| `execcommandwithoutcontext` | Custom `go/analysis` analyzer that flags `exec.Command(...)` calls that should use `exec.CommandContext(...)` in context-receiving functions |
| `fileclosenotdeferred` | Custom `go/analysis` analyzer that flags file `Close()` calls that are not deferred immediately |
| `fmterrorfnoverbs` | Custom `go/analysis` analyzer that flags `fmt.Errorf` calls with no format verbs, recommending `errors.New` |
| `fprintlnsprintf` | Custom `go/analysis` analyzer that flags `fmt.Fprintln(..., fmt.Sprintf(...))` patterns |
Expand Down Expand Up @@ -76,6 +78,7 @@ import (
"github.com/github/gh-aw/pkg/linters/excessivefuncparams"
"github.com/github/gh-aw/pkg/linters/errormessage"
"github.com/github/gh-aw/pkg/linters/errstringmatch"
"github.com/github/gh-aw/pkg/linters/execcommandwithoutcontext"
"github.com/github/gh-aw/pkg/linters/fileclosenotdeferred"
"github.com/github/gh-aw/pkg/linters/largefunc"
"github.com/github/gh-aw/pkg/linters/lenstringzero"
Expand All @@ -93,6 +96,7 @@ _ = ctxbackground.Analyzer
_ = excessivefuncparams.Analyzer
_ = errormessage.Analyzer
_ = errstringmatch.Analyzer
_ = execcommandwithoutcontext.Analyzer
_ = fileclosenotdeferred.Analyzer
_ = largefunc.Analyzer
_ = lenstringzero.Analyzer
Expand All @@ -112,6 +116,7 @@ _ = ssljson.Analyzer
- `github.com/github/gh-aw/pkg/linters/ctxbackground` — context-background analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/errormessage` — error-message analyzer subpackage (also re-exported as `ErrorMessageAnalyzer`)
- `github.com/github/gh-aw/pkg/linters/errstringmatch` — err-string-match analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/execcommandwithoutcontext` — exec-command-without-context analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/excessivefuncparams` — excessive-func-params analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/fileclosenotdeferred` — file-close-not-deferred analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/fmterrorfnoverbs` — fmt-errorf-no-verbs analyzer subpackage
Expand Down
3 changes: 2 additions & 1 deletion pkg/linters/doc.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// Package linters is a namespace for gh-aw's custom Go analysis linters.
//
// The actual analyzers are implemented in subpackages. All 23 active analyzers:
// The actual analyzers are implemented in subpackages. All 24 active analyzers:
//
// - contextcancelnotdeferred — flags context cancel functions called directly instead of deferred
// - ctxbackground — flags context.Background() inside functions that already receive a context
// - errormessage — flags non-actionable error message patterns in changed files
// - errstringmatch — flags brittle strings.Contains(err.Error(), "...") checks
// - excessivefuncparams — flags function declarations with too many positional parameters
// - execcommandwithoutcontext — flags exec.Command calls inside functions that already receive context.Context
// - fileclosenotdeferred — flags file Close() calls that are not deferred
// - fmterrorfnoverbs — flags fmt.Errorf calls with no format verbs, recommending errors.New
// - fprintlnsprintf — flags fmt.Fprintln(..., fmt.Sprintf(...)) patterns
Expand Down
159 changes: 159 additions & 0 deletions pkg/linters/execcommandwithoutcontext/execcommandwithoutcontext.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Package execcommandwithoutcontext implements a Go analysis linter that flags
// calls to exec.Command inside functions that already receive a context.Context
// parameter, where exec.CommandContext should be used instead to propagate
// cancellation.
package execcommandwithoutcontext

import (
"fmt"
"go/ast"
"go/types"

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

"github.com/github/gh-aw/pkg/linters/internal/filecheck"
)

// Analyzer is the exec-command-without-context analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "execcommandwithoutcontext",
Doc: "reports exec.Command calls inside context-receiving functions where exec.CommandContext should be used to propagate cancellation",
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/execcommandwithoutcontext",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}

func run(pass *analysis.Pass) (any, error) {
insp, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
if !ok {
return nil, fmt.Errorf("inspect analyzer result has unexpected type %T", pass.ResultOf[inspect.Analyzer])
}

for cur := range insp.Root().Preorder((*ast.CallExpr)(nil)) {
call, ok := cur.Node().(*ast.CallExpr)
if !ok {
continue
}
sel, ok := execCommandSelector(pass, call)
if !ok {
continue
}

pos := pass.Fset.PositionFor(call.Pos(), false)
if filecheck.IsTestFile(pos.Filename) {
continue
}

for encl := range cur.Enclosing((*ast.FuncDecl)(nil), (*ast.FuncLit)(nil)) {
funcType := enclosingFuncType(encl.Node())
if funcType == nil {
continue

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: encl.Node().(*ast.FuncDecl) can never return ok == false here because cur.Enclosing((*ast.FuncDecl)(nil)) only yields nodes whose dynamic type matches the filter, making the if !ok { continue } guard unreachable and misleading about whether the assertion can fail.

💡 Simplified
for encl := range cur.Enclosing((*ast.FuncDecl)(nil)) {
    fn := encl.Node().(*ast.FuncDecl) // assertion is always safe here
    ctxParamName, hasCtx := contextParamName(pass, fn)
    if !hasCtx {
        break
    }
    pass.Report(...)
    break
}

Note also: since Go does not permit named function declarations to nest inside other named functions, this loop iterates at most once. The double-break + surrounding for is a byproduct of defensive coding against a topological impossibility.

}
ctxParamName, hasCtx := contextParamName(pass, funcType)
if !hasCtx {
continue
}
pass.Report(analysis.Diagnostic{

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] No SuggestedFix is provided — the only linter in this family that does provide one (ctxbackground) validates fixes with analysistest.RunWithSuggestedFixes. The transformation here is equally deterministic and should be offered as an auto-fix.

💡 Suggested implementation

Return the selector from isExecCommandCall (or rename it to return *ast.SelectorExpr) so the run function has access to the positions it needs:

pass.Report(analysis.Diagnostic{
    Pos:     call.Pos(),
    End:     call.End(),
    Message: fmt.Sprintf("use exec.CommandContext(%s, ...) ...", ctxParamName),
    SuggestedFixes: []analysis.SuggestedFix{
        {
            Message: "Replace exec.Command with exec.CommandContext",
            TextEdits: []analysis.TextEdit{
                // rename Command → CommandContext
                {Pos: sel.Sel.Pos(), End: sel.Sel.End(), NewText: []byte("CommandContext")},
                // insert ctx as first argument
                {Pos: call.Lparen + 1, End: call.Lparen + 1, NewText: []byte(ctxParamName + ", ")},
            },
        },
    },
})

The test should then use analysistest.RunWithSuggestedFixes and include a .golden fixture file.

Pos: call.Pos(),
End: call.End(),
Message: fmt.Sprintf("use exec.CommandContext(%s, ...) instead of exec.Command to propagate context cancellation", ctxParamName),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No SuggestedFix is provided; the structurally identical sibling linter ctxbackground does provide one, and the transform here is equally deterministic: rename CommandCommandContext and prepend ctx as the first argument.

💡 Suggested implementation
sel := call.Fun.(*ast.SelectorExpr) // safe: isExecCommandCall verified this
pass.Report(analysis.Diagnostic{
    Pos:     call.Pos(),
    End:     call.End(),
    Message: fmt.Sprintf("use exec.CommandContext(%s, ...) instead of exec.Command to propagate context cancellation", ctxParamName),
    SuggestedFixes: []analysis.SuggestedFix{{
        Message: fmt.Sprintf("Replace exec.Command with exec.CommandContext(%s, ...)", ctxParamName),
        TextEdits: []analysis.TextEdit{
            // Rename selector: Command → CommandContext
            {
                Pos:     sel.Sel.Pos(),
                End:     sel.Sel.End(),
                NewText: []byte("CommandContext"),
            },
            // Insert ctx as first argument (position right after opening paren)
            {
                Pos:     call.Lparen + 1,
                End:     call.Lparen + 1,
                NewText: []byte(ctxParamName + ", "),
            },
        },
    }},
})

To make sel available here, either return it from isExecCommandCall (changing its signature to (*ast.SelectorExpr, bool)) or re-assert call.Fun.(*ast.SelectorExpr) directly — the assertion is guaranteed safe because isExecCommandCall already verified it.

SuggestedFixes: []analysis.SuggestedFix{
{
Message: fmt.Sprintf("Replace exec.Command with exec.CommandContext(%s, ...)", ctxParamName),
TextEdits: []analysis.TextEdit{
{
Pos: sel.Sel.Pos(),
End: sel.Sel.End(),
NewText: []byte("CommandContext"),
},
{
Pos: call.Lparen + 1,
End: call.Lparen + 1,
NewText: []byte(ctxParamName + ", "),
},
},
},
},
})
break
}
}

return nil, nil
}

// execCommandSelector reports the selector expression for calls to
// exec.Command from os/exec.
func execCommandSelector(pass *analysis.Pass, call *ast.CallExpr) (*ast.SelectorExpr, bool) {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Command" {
return nil, false
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return nil, false
}
obj := pass.TypesInfo.ObjectOf(ident)
if obj == nil {
return nil, false
}
pkgName, ok := obj.(*types.PkgName)
if !ok {
return nil, false
}
if pkgName.Imported().Path() != "os/exec" {
return nil, false
}
return sel, true
}

// contextParamName returns the name of the first context.Context parameter
// in fn, and true, or "", false if none exists.
func contextParamName(pass *analysis.Pass, fn *ast.FuncType) (string, bool) {
if fn == nil || fn.Params == nil {
return "", false
}
ctxType := contextContextType(pass)
if ctxType == nil {
return "", false
}
for _, field := range fn.Params.List {
t := pass.TypesInfo.TypeOf(field.Type)
if t == nil || !types.Identical(t, ctxType) {
continue
}
for _, name := range field.Names {
if name.Name != "_" {
return name.Name, true
}
}
}
return "", false
}

func enclosingFuncType(node ast.Node) *ast.FuncType {
switch fn := node.(type) {
case *ast.FuncDecl:
return fn.Type
case *ast.FuncLit:
return fn.Type
default:
return nil
}
}

// contextContextType returns the types.Type for context.Context, or nil if
// the context package is not imported.
func contextContextType(pass *analysis.Pass) types.Type {
for _, pkg := range pass.Pkg.Imports() {
if pkg.Path() == "context" {
obj := pkg.Scope().Lookup("Context")
if obj != nil {
return obj.Type()
}
}
}
return nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !integration

package execcommandwithoutcontext_test

import (
"testing"

"golang.org/x/tools/go/analysis/analysistest"

"github.com/github/gh-aw/pkg/linters/execcommandwithoutcontext"
)

func TestAnalyzer(t *testing.T) {
testdata := analysistest.TestData()
analysistest.RunWithSuggestedFixes(t, testdata, execcommandwithoutcontext.Analyzer, "execcommandwithoutcontext")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package execcommandwithoutcontext

import (
"context"
"os/exec"
)

// Bad: exec.Command inside a function that already receives a context.
func BadRunCommand(ctx context.Context, name string) error {
cmd := exec.Command(name) // want `use exec\.CommandContext\(ctx, \.\.\.\) instead of exec\.Command to propagate context cancellation`
return cmd.Run()
}

// Bad: exec.Command with extra args inside a context-receiving function.
func BadRunCommandArgs(ctx context.Context, name string, args ...string) {
cmd := exec.Command(name, args...) // want `use exec\.CommandContext\(ctx, \.\.\.\) instead of exec\.Command to propagate context cancellation`
_ = cmd
}

// Good: exec.CommandContext is used correctly.
func GoodRunCommandContext(ctx context.Context, name string) error {
cmd := exec.CommandContext(ctx, name)
return cmd.Run()
}

// Good: no context parameter, exec.Command is fine.
func GoodNoContext(name string) error {
cmd := exec.Command(name)
return cmd.Run()
}

// Good: context parameter is blank, exec.Command is acceptable.
func GoodBlankContext(_ context.Context, name string) error {
cmd := exec.Command(name)
return cmd.Run()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test data is missing all closure/anonymous-function scenarios, leaving the linter's behavior in those cases entirely undocumented and unverified.

💡 Cases to add
func doWork(fn func(context.Context, string)) { fn(context.Background(), "ls") }

// Bad: exec.Command inside a function literal that itself receives context.
func BadFuncLitCtx() {
    doWork(func(ctx context.Context, name string) {
        _ = exec.Command(name) // want `use exec\.CommandContext\(ctx, \.\.\.\)`
    })
}

// Good: function literal with context that correctly uses CommandContext.
func GoodFuncLitCtx() {
    doWork(func(ctx context.Context, name string) {
        _ = exec.CommandContext(ctx, "ls")
    })
}

// Behaviour documentation: exec.Command inside an unlabelled closure nested
// in a context-receiving named function — the outer ctx name is reported.
func OuterCtxInnerClosure(ctx context.Context) {
    go func() {
        _ = exec.Command("ls") // want `use exec\.CommandContext\(ctx, \.\.\.\)`
    }()
}

The first case (BadFuncLitCtx) will currently not be flagged because of the *ast.FuncLit gap described in the companion comment on line 45. Adding it as a // want expectation will make the test fail and confirm the regression until the fix lands.

}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Missing method-receiver test case — the sibling ctxbackground fixture explicitly covers this scenario:

type Runner struct{}

func (r *Runner) Run(ctx context.Context, name string) error {
    cmd := exec.Command(name) // want `use exec\.CommandContext\(ctx, \.\.\.\).*`
    return cmd.Run()
}

Since FuncDecl covers both top-level functions and methods, this should work already — but without a test, a refactor could silently regress it.


type Runner struct{}

// Bad: method with context parameter should use CommandContext.
func (r *Runner) Run(ctx context.Context, name string) error {
cmd := exec.Command(name) // want `use exec\.CommandContext\(ctx, \.\.\.\) instead of exec\.Command to propagate context cancellation`
return cmd.Run()
}

func doWork(fn func(context.Context, string)) {
fn(context.Background(), "ls")
}

// Bad: function literal with context parameter should use CommandContext.
func BadFuncLitCtx() {
doWork(func(ctx context.Context, name string) {
_ = exec.Command(name) // want `use exec\.CommandContext\(ctx, \.\.\.\) instead of exec\.Command to propagate context cancellation`
})
}

// Good: function literal with context parameter already uses CommandContext.
func GoodFuncLitCtx() {
doWork(func(ctx context.Context, name string) {
_ = exec.CommandContext(ctx, name)
})
}

// Bad: closure in context-receiving function should use outer ctx.
func OuterCtxInnerClosure(ctx context.Context) {
go func() {
_ = exec.Command("ls") // want `use exec\.CommandContext\(ctx, \.\.\.\) instead of exec\.Command to propagate context cancellation`
}()
}
Loading
Loading