Skip to content
Closed
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 @@ -28,6 +28,7 @@ import (
"github.com/github/gh-aw/pkg/linters/fprintlnsprintf"
"github.com/github/gh-aw/pkg/linters/hardcodedfilepath"
"github.com/github/gh-aw/pkg/linters/httpnoctx"
"github.com/github/gh-aw/pkg/linters/httpresponsebodyclose"
"github.com/github/gh-aw/pkg/linters/jsonmarshalignoredeerror"
"github.com/github/gh-aw/pkg/linters/largefunc"
"github.com/github/gh-aw/pkg/linters/lenstringzero"
Expand Down Expand Up @@ -61,6 +62,7 @@ func main() {
fmterrorfnoverbs.Analyzer,
hardcodedfilepath.Analyzer,
httpnoctx.Analyzer,
httpresponsebodyclose.Analyzer,
largefunc.Analyzer,
manualmutexunlock.Analyzer,
osexitinlibrary.Analyzer,
Expand Down
42 changes: 42 additions & 0 deletions docs/adr/40130-custom-linter-for-non-deferred-http-body-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ADR-40130: Ship a Custom `httpresponsebodyclose` Linter for Non-Deferred HTTP Response Body Close

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

## Context

Calling `resp.Body.Close()` directly — rather than `defer resp.Body.Close()` — risks leaking the underlying connection: if the function returns early on a later error, panics, or gains a new `return` during maintenance, the close never runs. A scan of this repository (run #42) found a real occurrence at `pkg/cli/mcp_inspect_mcp_scripts_server.go:67`, where the body is closed inside an `if`-init assignment without a defer. No commonly enabled linter flags this: `go vet` does not track response-body lifetime, and the standard `bodyclose` linter is not part of this project's enabled set. The project maintains an in-repo `go/analysis` linter suite (`pkg/linters/*`, registered in `cmd/linters/main.go`) that is the established home for such repository-specific correctness guards.

## Decision

We will add a bespoke `go/analysis` analyzer, `httpresponsebodyclose`, to the project's custom linter suite rather than adopting an external linter. The analyzer walks each non-test `*ast.FuncDecl`, tracks variables whose type is `*net/http.Response` (verified via `types.Object` identity against `net/http`, not a syntactic name match), and records for each whether its `Body.Close()` is deferred versus called manually (either as a bare expression statement or captured in an assignment such as `closeErr := resp.Body.Close()`). When a response variable has a manual close but no deferred close, it reports a diagnostic at the response-acquisition site so the warning highlights where `defer resp.Body.Close()` should be added. It does not descend into function literals, so closures are analyzed independently to avoid false positives, and it is registered in the multichecker in `cmd/linters/main.go`.

## Alternatives Considered

### Alternative 1: Adopt the external `bodyclose` linter
The community `bodyclose` analyzer (via `golangci-lint`) detects unclosed response bodies. Rejected because this project deliberately curates a small, self-contained in-repo linter suite with shared `internal` helpers (`astutil`, `filecheck`); pulling in an external analyzer with broader, differently-scoped semantics would not compose with that suite and would target "never closed" rather than this project's specific "closed but not deferred" concern.

### Alternative 2: Document the convention and rely on code review
A contributor guideline plus manual review requires no new code. Rejected because the early-return/panic leak is subtle and easy to miss — an instance already exists in the codebase — and manual review provides no repeatable, enforceable guard in CI.

### Alternative 3: Syntactic detection by identifier name (`resp.Body.Close()`)
A simpler analyzer could flag any `Body.Close()` whose receiver is named `resp`. Rejected because it would miss response variables under other names and false-positive on unrelated types that happen to expose a `Body.Close()`; type-based `*net/http.Response` identity checking is more precise at modest extra complexity.

## Consequences

### Positive
- Catches a real, otherwise-undetected resource-leak pattern automatically in CI.
- Follows the established in-repo linter convention, mirroring `fileclosenotdeferred` in structure, so it composes with the existing `cmd/linters` multichecker and shared `internal` helpers.
- Precise: type-identity checking against `net/http.Response` targets genuine response bodies while ignoring same-named methods on unrelated types.

### Negative
- Adds a custom analyzer the team must maintain, including AST handling for both expression-statement and assignment-captured close calls.
- Intra-procedural and pattern-bound: it only recognizes the `resp.Body.Close()` receiver shape and does not follow a body passed across function boundaries or aliased, so some manual-close leaks remain uncaught — which may create a false sense of full coverage.

### Neutral
- Test files are excluded from analysis, matching the suite's existing conventions.
- The analyzer reports at the response-acquisition site, not at the later manual `Close()` call site.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27789122704) 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 @@ -18,6 +18,7 @@ This package currently provides custom Go analyzers in the following subpackages
- `fprintlnsprintf` — reports `fmt.Fprintln(..., fmt.Sprintf(...))` patterns and recommends direct formatting calls.
- `hardcodedfilepath` — reports hard-coded file path string literals that match known path constants or should be extracted into named constants; also annotates paths that appear in log/print calls.
- `httpnoctx` — reports HTTP client and package-level HTTP calls that do not accept a `context.Context`.
- `httpresponsebodyclose` — reports HTTP response `Body.Close()` calls that are not deferred immediately after a successful request.
- `jsonmarshalignoredeerror` — reports `json.Marshal` and `json.Unmarshal` calls where the error return is discarded.
- `largefunc` — reports function bodies that exceed a configurable line-count threshold.
- `lenstringzero` — reports `len(s) == 0` / `len(s) != 0` comparisons on string values that should use `s == ""` / `s != ""`.
Expand Down Expand Up @@ -55,6 +56,7 @@ This package currently provides custom Go analyzers in the following subpackages
| `fprintlnsprintf` | Custom `go/analysis` analyzer that flags `fmt.Fprintln(..., fmt.Sprintf(...))` patterns |
| `hardcodedfilepath` | Custom `go/analysis` analyzer that flags hard-coded file path string literals that match known path constants or should be extracted as named constants; annotates paths in log/print calls |
| `httpnoctx` | Custom `go/analysis` analyzer that flags HTTP calls that do not accept a `context.Context` |
| `httpresponsebodyclose` | Custom `go/analysis` analyzer that flags HTTP response `Body.Close()` calls that are not deferred immediately |
| `jsonmarshalignoredeerror` | Custom `go/analysis` analyzer that flags `json.Marshal`/`json.Unmarshal` calls where the error return is discarded |
| `largefunc` | Custom `go/analysis` analyzer that flags large functions with actionable diagnostics |
| `lenstringzero` | Custom `go/analysis` analyzer that flags `len(s) == 0` / `len(s) != 0` on string values that should use `s == ""` / `s != ""` |
Expand Down Expand Up @@ -91,6 +93,7 @@ import (
"github.com/github/gh-aw/pkg/linters/execcommandwithoutcontext"
"github.com/github/gh-aw/pkg/linters/fileclosenotdeferred"
"github.com/github/gh-aw/pkg/linters/hardcodedfilepath"
"github.com/github/gh-aw/pkg/linters/httpresponsebodyclose"
"github.com/github/gh-aw/pkg/linters/largefunc"
"github.com/github/gh-aw/pkg/linters/lenstringzero"
"github.com/github/gh-aw/pkg/linters/manualmutexunlock"
Expand All @@ -110,6 +113,7 @@ _ = errstringmatch.Analyzer
_ = execcommandwithoutcontext.Analyzer
_ = fileclosenotdeferred.Analyzer
_ = hardcodedfilepath.Analyzer
_ = httpresponsebodyclose.Analyzer
_ = largefunc.Analyzer
_ = lenstringzero.Analyzer
_ = manualmutexunlock.Analyzer
Expand All @@ -134,6 +138,7 @@ _ = ssljson.Analyzer
- `github.com/github/gh-aw/pkg/linters/fmterrorfnoverbs` — fmt-errorf-no-verbs analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/fprintlnsprintf` — fprintln-sprintf analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/hardcodedfilepath` — hard-coded-file-path analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/httpresponsebodyclose` — HTTP-response-body-close analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/jsonmarshalignoredeerror` — json-marshal-ignored-error analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/largefunc` — large-func analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/lenstringzero` — len-string-zero 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,6 +1,6 @@
// Package linters is a namespace for gh-aw's custom Go analysis linters.
//
// The actual analyzers are implemented in subpackages. All 29 active analyzers:
// The actual analyzers are implemented in subpackages. All 30 active analyzers:
//
// - contextcancelnotdeferred — flags context cancel functions called directly instead of deferred
// - ctxbackground — flags context.Background() inside functions that already receive a context
Expand All @@ -13,6 +13,7 @@
// - fmterrorfnoverbs — flags fmt.Errorf calls with no format verbs, recommending errors.New
// - fprintlnsprintf — flags fmt.Fprintln(..., fmt.Sprintf(...)) patterns
// - httpnoctx — flags HTTP calls that do not accept a context.Context
// - httpresponsebodyclose — flags HTTP response Body.Close() calls that are not deferred
// - jsonmarshalignoredeerror — flags json.Marshal/Unmarshal calls where the error is discarded with _
// - largefunc — flags function bodies that exceed a configurable line-count threshold
// - lenstringzero — flags len(s) == 0 / len(s) != 0 on string values that should use s == "" / s != ""
Expand Down
202 changes: 202 additions & 0 deletions pkg/linters/httpresponsebodyclose/httpresponsebodyclose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Package httpresponsebodyclose implements a Go analysis linter that flags
// HTTP response bodies that are closed manually instead of via defer.
package httpresponsebodyclose

import (
"go/ast"
"go/token"
"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"
)

// Analyzer is the http-response-body-close analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "httpresponsebodyclose",
Doc: "reports HTTP response Body.Close() calls that are not deferred, which can cause resource leaks on early return or panic",
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/httpresponsebodyclose",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}

func run(pass *analysis.Pass) (any, error) {
insp, err := astutil.Inspector(pass)
if err != nil {
return nil, err
}

nodeFilter := []ast.Node{
(*ast.FuncDecl)(nil),
}

insp.Preorder(nodeFilter, func(n ast.Node) {
inspectHTTPResponseFuncDecl(pass, n)
})

return nil, nil
}

type responseVarState struct {
assignPos token.Pos
hasDefer bool
hasManualClose bool
}

func inspectHTTPResponseFuncDecl(pass *analysis.Pass, n ast.Node) {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Body == nil {
return
}

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

responseVars := make(map[types.Object]*responseVarState)

ast.Inspect(fn.Body, func(node ast.Node) bool {
return analyzeResponseNode(pass, responseVars, node)
})

for _, state := range responseVars {
if state.hasManualClose && !state.hasDefer {
pass.Report(analysis.Diagnostic{
Pos: state.assignPos,
Message: "HTTP response Body.Close() should be deferred immediately after error check to prevent resource leaks",
})
}
}
}

func analyzeResponseNode(pass *analysis.Pass, responseVars map[types.Object]*responseVarState, node ast.Node) bool {
if node == nil {
return false
}

// Do not descend into function literals — closures are independent execution
// contexts and should be analyzed separately to avoid false positives.
if _, ok := node.(*ast.FuncLit); ok {

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] Closure boundary isolation is a key correctness requirement with no testdata fixture.

Skipping *ast.FuncLit prevents false positives when a defer resp.Body.Close() inside a closure is mistakenly credited to the outer function's responseVars. Without a test, a future change to the walk could silently re-introduce those false positives.

💡 Suggested fixture to add (mirrors `fileclosenotdeferred`'s `OpenInClosure` case)
// not flagged: the defer is inside a closure, independent execution context.
func fetchInClosure(url string) {
	fn := func() error {
		resp, err := http.DefaultClient.Get(url)
		if err != nil {
			return err
		}
		defer resp.Body.Close()
		_, _ = io.ReadAll(resp.Body)
		return nil
	}
	_ = fn()
}

Also worth adding the inverse: manual close inside a closure that should be flagged within that closure.

return false
}

if assign, ok := node.(*ast.AssignStmt); ok {
trackResponseAssignment(pass, responseVars, assign)
}

if deferStmt, ok := node.(*ast.DeferStmt); ok {
if obj := getHTTPBodyCloseObject(pass, deferStmt.Call); obj != nil {
if state, found := responseVars[obj]; found {
state.hasDefer = true
}
}
}

// Non-deferred resp.Body.Close() in expression statements.
if exprStmt, ok := node.(*ast.ExprStmt); ok {
if call, ok := exprStmt.X.(*ast.CallExpr); ok {
markManualBodyClose(pass, responseVars, call)
}
}

// Non-deferred resp.Body.Close() captured in an assignment (e.g. closeErr := resp.Body.Close()).
if assign, ok := node.(*ast.AssignStmt); ok {
for _, rhs := range assign.Rhs {
if call, ok := rhs.(*ast.CallExpr); ok {
markManualBodyClose(pass, responseVars, call)
}
}
}

return true
}

func trackResponseAssignment(pass *analysis.Pass, responseVars map[types.Object]*responseVarState, assign *ast.AssignStmt) {
// Track any binding whose LHS has type *net/http.Response. Unlike
// fileclosenotdeferred (which tracks explicit os.Open/os.Create call sites),
// this intentionally keys on the assigned variable's type so responses
// returned by helper functions are also covered.
for _, lhs := range assign.Lhs {
ident, ok := lhs.(*ast.Ident)
if !ok || ident.Name == "_" {
continue
}
obj := pass.TypesInfo.ObjectOf(ident)
if obj == nil {
continue
}
if !isHTTPResponseType(obj.Type()) {

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.

[/zoom-out] Tracking by LHS type vs by RHS call-site is a meaningful design difference from fileclosenotdeferred — worth a brief comment.

fileclosenotdeferred only tracks variables from explicit os.Open / os.Create / os.OpenFile calls. This linter tracks any assignment to a *net/http.Response-typed variable, including responses returned from helpers. That is intentionally broader and catches more real patterns, but it is a different philosophy that a future maintainer may not expect. A short comment on trackResponseAssignment explaining this choice (and that it is intentional) would preserve the decision context.

💡 Suggested comment
// trackResponseAssignment registers any assignment whose LHS has type *net/http.Response.
// Unlike fileclosenotdeferred (which tracks only explicit os.Open/os.Create calls),
// we key on the variable's type rather than the call site so that responses returned
// by helper functions are also tracked.
func trackResponseAssignment(...) {

continue
}
if prev, exists := responseVars[obj]; exists && prev.hasManualClose && !prev.hasDefer {
pass.Report(analysis.Diagnostic{
Pos: prev.assignPos,
Message: "HTTP response Body.Close() should be deferred immediately after error check to prevent resource leaks",
})
}
assignPos := ident.Pos()
if len(assign.Rhs) == 1 {
if call, ok := assign.Rhs[0].(*ast.CallExpr); ok {
assignPos = call.Pos()
}
}
responseVars[obj] = &responseVarState{assignPos: assignPos}
}
}

func markManualBodyClose(pass *analysis.Pass, responseVars map[types.Object]*responseVarState, call *ast.CallExpr) {
obj := getHTTPBodyCloseObject(pass, call)
if obj == nil {
return
}
if state, found := responseVars[obj]; found {
state.hasManualClose = true
}
}

// getHTTPBodyCloseObject returns the types.Object for the *http.Response variable
// in a resp.Body.Close() call, or nil if the call does not match that pattern.
// Known limitation: body aliasing (body := resp.Body; body.Close()) is not
// detected because the selector chain no longer starts from the *http.Response
// variable directly.
func getHTTPBodyCloseObject(pass *analysis.Pass, call *ast.CallExpr) types.Object {

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] Known false negative for body-aliasing is undocumented.

getHTTPBodyCloseObject only recognises the resp.Body.Close() selector chain directly on the response variable. If a caller aliases the body first — body := resp.Body; body.Close() — the linter produces no diagnostic, which is a silent resource leak. This is a reasonable scope limitation, but it should be called out in a comment so future maintainers understand the boundary and don't expect it to be covered.

💡 Suggested doc comment addition on `getHTTPBodyCloseObject`
// getHTTPBodyCloseObject returns the types.Object for the *http.Response variable
// in a resp.Body.Close() call, or nil if the call does not match that pattern.
//
// Known limitation: body aliasing (`body := resp.Body; body.Close()`) is not
// detected because the selector chain no longer starts from the *http.Response
// variable directly.
func getHTTPBodyCloseObject(pass *analysis.Pass, call *ast.CallExpr) types.Object {

sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Close" {
return nil
}
// The receiver must be an expression of the form resp.Body.
bodyExpr, ok := sel.X.(*ast.SelectorExpr)
if !ok || bodyExpr.Sel.Name != "Body" {
return nil
}
ident, ok := bodyExpr.X.(*ast.Ident)
if !ok {
return nil
}
obj := pass.TypesInfo.ObjectOf(ident)
if obj == nil {
return nil
}
if !isHTTPResponseType(obj.Type()) {
return nil
}
return obj
}

// isHTTPResponseType reports whether t is *net/http.Response.
func isHTTPResponseType(t types.Type) bool {
ptr, ok := t.(*types.Pointer)
if !ok {
return false
}
named, ok := ptr.Elem().(*types.Named)
if !ok {
return false
}
obj := named.Obj()
return obj.Name() == "Response" && obj.Pkg() != nil && obj.Pkg().Path() == "net/http"
}
16 changes: 16 additions & 0 deletions pkg/linters/httpresponsebodyclose/httpresponsebodyclose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !integration

package httpresponsebodyclose_test

import (
"testing"

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

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

func TestHTTPResponseBodyClose(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, httpresponsebodyclose.Analyzer, "httpresponsebodyclose")
}
Loading