-
Notifications
You must be signed in to change notification settings - Fork 457
feat(linters): add nilctxpassed analyzer #45799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
bf4911b
Initial plan
Copilot 38eba39
feat(linters): add nilctxpassed linter
Copilot 7ef6fc3
fix(nilctxpassed): clarify test fixture comment
Copilot a611d59
docs(adr): add draft ADR-45799 for nilctxpassed linter
github-actions[bot] 037a4f6
fix(nilctxpassed): detect context.Context by type inspection, fix nol…
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # ADR-45799: Add nilctxpassed Static Analyzer to Catch nil context.Context Arguments | ||
|
|
||
| **Date**: 2026-07-15 | ||
| **Status**: Draft | ||
| **Deciders**: pelikhan, copilot-swe-agent | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| Passing `nil` as a `context.Context` argument compiles cleanly in Go because `nil` is a valid zero value for any interface type, including `context.Context`. However, any call to a method on a `nil` context (e.g., `ctx.Deadline()`, `ctx.Done()`, `ctx.Value()`) causes a runtime panic that is difficult to diagnose. The existing linter suite (`pkg/linters/`) already uses the `golang.org/x/tools/go/analysis` framework and shared helpers (`astutil`, `nolint`, `filecheck`) to catch similar classes of bugs at analysis time. Adding `nilctxpassed` closes the gap between "compiles successfully" and "safe to run" for a common mistake pattern. | ||
|
|
||
| ### Decision | ||
|
|
||
| We will add a new `nilctxpassed` analyzer (`pkg/linters/nilctxpassed/nilctxpassed.go`) that inspects every call expression in non-generated Go source files, resolves the callee's `*types.Signature`, and reports a diagnostic whenever the predeclared `nil` (confirmed via `*types.Nil`) is passed in a position whose parameter type is identical to `context.Context`. The analyzer supports variadic positions and respects `//nolint:nilctxpassed` suppression. It is registered in the `cmd/linters/main.go` multichecker binary alongside all existing analyzers. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Rely on the Go type system alone | ||
|
|
||
| Go's compiler rejects most non-interface mismatches, but `nil` is assignable to any interface type including `context.Context`. No compiler error or warning is emitted when a developer writes `f(nil)` where `f` accepts `context.Context`. This alternative provides zero protection against the pattern at build time, so it was rejected. | ||
|
|
||
| #### Alternative 2: Runtime guard / defensive nil checks inside every context-accepting function | ||
|
|
||
| Each function that accepts a `context.Context` could panic or return an error immediately if `ctx == nil`. This defensive check is idiomatic in some codebases but requires manual discipline across every function signature, adds per-call overhead, produces less actionable error messages than a linter diagnostic with source location, and does not scale to third-party code that does not own the function definitions. It was rejected in favour of a single-point static check that enforces the constraint uniformly. | ||
|
|
||
| #### Alternative 3: Use an off-the-shelf linter (e.g., `noctx` from `golangci-lint`) | ||
|
|
||
| Existing third-party linters target related but distinct problems (e.g., HTTP requests made without a context). None of the commonly used linters in `golangci-lint` precisely flag nil-as-context-argument using type-identity checks. Additionally, the project's linter suite is a custom multichecker binary that requires analyzers to be written using the same internal helpers (`astutil`, `nolint`, `filecheck`) for consistent nolint suppression and generated-file skipping. Adopting a third-party linter would mean a different suppression mechanism and would not integrate with the shared `nolint.Analyzer` dependency. A bespoke analyzer was preferred. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - Eliminates an entire class of runtime panic (`nil` context) by making it a build-time error, catching bugs before they reach staging or production. | ||
| - Follows the established pattern of the linter suite: same `go/analysis` framework, same shared helpers, same `//nolint:<linter>` suppression mechanism — no new concepts for contributors to learn. | ||
| - The `analysistest`-based test suite provides deterministic, reproducible verification of both flagged and non-flagged cases without runtime execution. | ||
| - Generated files are automatically excluded from analysis via the existing `filecheck.Analyzer` dependency. | ||
|
|
||
| #### Negative | ||
| - Adds one more analysis pass to the multichecker binary, marginally increasing total analysis time (proportional to the number of call expressions in the codebase). | ||
| - The `//nolint:nilctxpassed` escape valve could be misused to suppress legitimate diagnostics in cases where `nil` is intentionally passed (e.g., tests that explicitly exercise nil-context handling). Teams must review nolint suppressions in code review. | ||
| - The analyzer only catches the predeclared `nil` identifier (confirmed via `*types.Nil`); a typed `var ctx context.Context` (whose zero value is also nil) would not be flagged, leaving a gap for that pattern. | ||
|
|
||
| #### Neutral | ||
| - The linter count in `pkg/linters/doc.go` and the spec test count increment from 44 to 45; the spec test `documentedAnalyzers()` list and README table must be kept in sync when adding future analyzers. | ||
| - The binary at `linters` (tracked in version control) was updated as part of this change. | ||
|
|
||
| --- | ||
|
|
||
| *ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* |
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| // Package nilctxpassed implements a Go analysis linter that flags function | ||
| // calls where nil is passed as a context.Context argument. | ||
| package nilctxpassed | ||
|
|
||
| import ( | ||
| "go/ast" | ||
| "go/types" | ||
|
|
||
| "golang.org/x/tools/go/analysis" | ||
| "golang.org/x/tools/go/analysis/passes/inspect" | ||
|
|
||
| "github.com/github/gh-aw/pkg/linters/internal/astutil" | ||
| "github.com/github/gh-aw/pkg/linters/internal/filecheck" | ||
| "github.com/github/gh-aw/pkg/linters/internal/nolint" | ||
| ) | ||
|
|
||
| // Analyzer is the nil-context-passed analysis pass. | ||
| var Analyzer = &analysis.Analyzer{ | ||
| Name: "nilctxpassed", | ||
| Doc: "reports function calls where nil is passed as a context.Context argument", | ||
| URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/nilctxpassed", | ||
| Requires: []*analysis.Analyzer{inspect.Analyzer, nolint.Analyzer, filecheck.Analyzer}, | ||
| Run: run, | ||
| } | ||
|
|
||
| func run(pass *analysis.Pass) (any, error) { | ||
| insp, err := astutil.Inspector(pass) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| noLintIndex, err := nolint.Index(pass) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| generatedFiles, err := filecheck.Index(pass) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| for cur := range insp.Root().Preorder((*ast.CallExpr)(nil)) { | ||
| call, ok := cur.Node().(*ast.CallExpr) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| callPos := pass.Fset.PositionFor(call.Pos(), false) | ||
| if filecheck.ShouldSkipFilename(callPos.Filename, generatedFiles) { | ||
| continue | ||
| } | ||
|
|
||
| sig := calleeSignature(pass, call) | ||
| if sig == nil { | ||
| continue | ||
| } | ||
|
|
||
| params := sig.Params() | ||
| for i, arg := range call.Args { | ||
| var paramType types.Type | ||
| if sig.Variadic() && params.Len() > 0 && i >= params.Len()-1 { | ||
| // Variadic: the last param is a slice; check its element type. | ||
| sliceType, ok := params.At(params.Len() - 1).Type().(*types.Slice) | ||
| if !ok { | ||
| continue | ||
| } | ||
| paramType = sliceType.Elem() | ||
| } else if i < params.Len() { | ||
| paramType = params.At(i).Type() | ||
| } else { | ||
| continue | ||
| } | ||
|
|
||
| if !isContextContext(paramType) { | ||
| continue | ||
| } | ||
|
|
||
| if !isBuiltinNil(pass, arg) { | ||
| continue | ||
| } | ||
|
|
||
| argPos := pass.Fset.PositionFor(arg.Pos(), false) | ||
| if nolint.HasDirectiveForLinter(argPos, noLintIndex, "nilctxpassed") { | ||
| continue | ||
| } | ||
|
|
||
| pass.Report(analysis.Diagnostic{ | ||
| Pos: arg.Pos(), | ||
| End: arg.End(), | ||
| Message: "nil passed as context.Context; use context.Background() or context.TODO() instead", | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| return nil, nil | ||
| } | ||
|
|
||
| // isContextContext reports whether t is the context.Context interface type, | ||
| // identified by inspecting the named type directly rather than relying on the | ||
| // current package importing "context". This catches cases where the analyzed | ||
| // package passes nil to a context.Context parameter from an external function | ||
| // without importing the context package itself. | ||
| func isContextContext(t types.Type) bool { | ||
| named, ok := t.(*types.Named) | ||
| if !ok { | ||
| return false | ||
| } | ||
| obj := named.Obj() | ||
| return obj.Pkg() != nil && obj.Pkg().Path() == "context" && obj.Name() == "Context" | ||
| } | ||
|
|
||
| // calleeSignature returns the *types.Signature of the callee if available. | ||
| func calleeSignature(pass *analysis.Pass, call *ast.CallExpr) *types.Signature { | ||
| if pass.TypesInfo == nil { | ||
| return nil | ||
| } | ||
| t := pass.TypesInfo.TypeOf(call.Fun) | ||
| if t == nil { | ||
| return nil | ||
| } | ||
| sig, ok := t.Underlying().(*types.Signature) | ||
| if !ok { | ||
| return nil | ||
| } | ||
| return sig | ||
| } | ||
|
|
||
| // isBuiltinNil reports whether expr is the predeclared nil identifier. | ||
| func isBuiltinNil(pass *analysis.Pass, expr ast.Expr) bool { | ||
| ident, ok := expr.(*ast.Ident) | ||
| if !ok || ident.Name != "nil" { | ||
| return false | ||
| } | ||
| if pass.TypesInfo == nil { | ||
| return false | ||
| } | ||
| obj := pass.TypesInfo.Uses[ident] | ||
| _, ok = obj.(*types.Nil) | ||
| return ok | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| //go:build !integration | ||
|
|
||
| package nilctxpassed_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "golang.org/x/tools/go/analysis/analysistest" | ||
|
|
||
| "github.com/github/gh-aw/pkg/linters/nilctxpassed" | ||
| ) | ||
|
|
||
| func TestNilCtxPassed(t *testing.T) { | ||
| testdata := analysistest.TestData() | ||
| analysistest.Run(t, testdata, nilctxpassed.Analyzer, "nilctxpassed", "nilctxpassed_noctx") | ||
| } |
11 changes: 11 additions & 0 deletions
11
pkg/linters/nilctxpassed/testdata/src/ctxhelper/ctxhelper.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| // Package ctxhelper provides helper functions used by nilctxpassed test | ||
| // fixtures to exercise calls from packages that do not directly import context. | ||
| package ctxhelper | ||
|
|
||
| import "context" | ||
|
|
||
| // TakesCtx accepts a context.Context parameter. | ||
| func TakesCtx(ctx context.Context) {} | ||
|
|
||
| // TakesOtherPointer accepts a *int parameter (not context.Context). | ||
| func TakesOtherPointer(p *int) {} |
70 changes: 70 additions & 0 deletions
70
pkg/linters/nilctxpassed/testdata/src/nilctxpassed/nilctxpassed.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package nilctxpassed | ||
|
|
||
| import ( | ||
| "context" | ||
| ) | ||
|
Comment on lines
+1
to
+5
|
||
|
|
||
| func takesCtx(ctx context.Context) {} | ||
|
|
||
| func takesCtxAndOther(ctx context.Context, n int) {} | ||
|
|
||
| func takesOtherAndCtx(n int, ctx context.Context) {} | ||
|
|
||
| func takesVariadicCtx(args ...context.Context) {} | ||
|
|
||
| type myKey struct{} | ||
|
|
||
| // flagged: nil passed as context.Context | ||
| func BadNilContext() { | ||
| takesCtx(nil) // want `nil passed as context\.Context; use context\.Background\(\) or context\.TODO\(\) instead` | ||
| } | ||
|
|
||
| // flagged: nil in first of two params | ||
| func BadNilFirstParam() { | ||
| takesCtxAndOther(nil, 42) // want `nil passed as context\.Context; use context\.Background\(\) or context\.TODO\(\) instead` | ||
| } | ||
|
|
||
| // flagged: nil in second positional context param | ||
| func BadNilSecondParam() { | ||
| takesOtherAndCtx(42, nil) // want `nil passed as context\.Context; use context\.Background\(\) or context\.TODO\(\) instead` | ||
| } | ||
|
|
||
| // flagged: nil as the only variadic context arg | ||
| func BadNilVariadic() { | ||
| takesVariadicCtx(nil) // want `nil passed as context\.Context; use context\.Background\(\) or context\.TODO\(\) instead` | ||
| } | ||
|
|
||
| // not flagged: proper context values | ||
| func GoodContextBackground() { | ||
| takesCtx(context.Background()) | ||
| } | ||
|
|
||
| func GoodContextTODO() { | ||
| takesCtx(context.TODO()) | ||
| } | ||
|
|
||
| // not flagged: non-context nil (e.g. error interface) | ||
| func takesError(err error) {} | ||
|
|
||
| func GoodNilError() { | ||
| takesError(nil) | ||
| } | ||
|
|
||
| // not flagged: a local context variable passed normally is fine | ||
| func GoodLocalCtx() { | ||
| ctx := context.Background() | ||
| takesCtx(ctx) | ||
| } | ||
|
|
||
| // not flagged: nolint suppresses the diagnostic (same line) | ||
| func NolintSuppressed() { | ||
| takesCtx(nil) //nolint:nilctxpassed | ||
| } | ||
|
|
||
| // not flagged: nolint on the nil arg line suppresses in a multiline call | ||
| func NolintSuppressedMultiline() { | ||
| takesCtxAndOther( | ||
| nil, //nolint:nilctxpassed | ||
| 42, | ||
| ) | ||
| } | ||
16 changes: 16 additions & 0 deletions
16
pkg/linters/nilctxpassed/testdata/src/nilctxpassed_noctx/nilctxpassed_noctx.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| // Package nilctxpassed_noctx tests the nilctxpassed analyzer for the case | ||
| // where the analyzed package does NOT directly import "context" but still | ||
| // passes nil to a context.Context parameter via an external function. | ||
| package nilctxpassed_noctx | ||
|
|
||
| import "ctxhelper" | ||
|
|
||
| // BadNoDirectImport passes nil to a context.Context param without importing context. | ||
| func BadNoDirectImport() { | ||
| ctxhelper.TakesCtx(nil) // want `nil passed as context\.Context; use context\.Background\(\) or context\.TODO\(\) instead` | ||
| } | ||
|
|
||
| // GoodNilNonContext passes nil to a non-context pointer param — not flagged. | ||
| func GoodNilNonContext() { | ||
| ctxhelper.TakesOtherPointer(nil) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] There is no test case for calling through a function variable or method value — e.g.
f := takesCtx; f(nil). ThecalleeSignaturepath handles this correctly viaTypesInfo.TypeOf, but without a test this regression surface is uncovered.💡 Suggested test case
Add to testdata:
@copilot please address this.