Skip to content

[linter-miner] feat(linters): add httprespbodyclose linter#42816

Merged
pelikhan merged 3 commits into
mainfrom
linter-miner/httprespbodyclose-1da525c394c9833b
Jul 2, 2026
Merged

[linter-miner] feat(linters): add httprespbodyclose linter#42816
pelikhan merged 3 commits into
mainfrom
linter-miner/httprespbodyclose-1da525c394c9833b

Conversation

@github-actions

@github-actions github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new custom Go static analysis linter, httprespbodyclose, to the gh-aw linter suite. The analyzer enforces that resp.Body.Close() calls on *net/http.Response variables are always made via defer, preventing resource leaks caused by non-deferred closes when a function returns early.


What changed

Area Change
pkg/linters/httprespbodyclose/ New analyzer package (214 lines)
cmd/linters/main.go Registers httprespbodyclose.Analyzer in the multichecker
pkg/cli/mcp_inspect_mcp_scripts_server.go Fixes pre-existing non-deferred resp.Body.Close() in waitForServerReady
docs/adr/42816-add-httprespbodyclose-linter.md New ADR-42816, Status: Accepted
pkg/linters/httprespbodyclose/httprespbodyclose_test.go analysistest harness
pkg/linters/httprespbodyclose/testdata/src/httprespbodyclose/httprespbodyclose.go 9 annotated test cases

Analyzer design

  • Detection scope: per-FuncDecl traversal; stops at FuncLit boundaries (closures treated as separate scope)
  • Type-awareness: uses go/types to confirm *net/http.Response from net/http package — not syntactic string matching
  • Tracking: per-function respVars map (types.Object → respVarState) records assignment position, defer close, and manual close
  • Reporting site: diagnostic reported at the HTTP call assignment, with a related diagnostic pointing to the manual Body.Close() call site
  • Reassignment: reports prior unresolved violation when the same resp variable is reassigned
  • Suppression: //nolint:httprespbodyclose on the assignment line
  • Test-file exclusion: skips files matching filecheck.IsTestFile, consistent with other suite analyzers
Test case coverage
Function Expected Reason
BadManualClose flagged direct Body.Close()
GoodDeferClose not flagged defer Body.Close()
GoodNoClose not flagged response returned to caller
GoodCloseInsideClosure not flagged closure boundary stops descent
GoodShadowedResp not flagged both shadowed vars use defer
BadReopenManualCloseThenDefer first assignment flagged manual close before reassignment
BadManualCloseAssigned flagged Body.Close() on assignment RHS
SuppressedOnAssignment not flagged //nolint:httprespbodyclose
SuppressedAcrossReassignment not flagged nolint on first assignment

Design decisions (ADR-42816)

Why not timakin/bodyclose? The gh-aw suite uses a custom multichecker with shared internals (astutil, filecheck, nolint). Adopting a third-party linter would bypass this infrastructure and introduce an external dependency.

Why not extend fileclosenotdeferred? *http.Response has different semantics from os.Open-family handles and requires net/http type resolution. Keeping them separate preserves clarity and independent testability.


Known limitations

  • Does not track *http.Response across function boundaries (cross-function close via helper → false negative)
  • Does not cover closures that capture and close resp from an outer scope (conservative: closure boundary terminates descent)

Commits

  • 6f523b20c feat(linters): add httprespbodyclose linter
  • 1ba3c1350 docs(adr): add draft ADR-42816 for httprespbodyclose linter
  • 9c6d53b32 test(linters): cover httprespbodyclose edge cases and finalize ADR

References: §28558128744

Generated by PR Description Updater for #42816 · 68.9 AIC · ⌖ 7.21 AIC · ⊞ 4.7K ·

Add a new custom Go analysis linter that flags HTTP response Body.Close()
calls that are made directly (without defer), which risks resource leaks
when the function returns early due to an error.

The linter tracks *http.Response variables assigned from HTTP calls
(using type information) and reports when resp.Body.Close() is called
without defer.

Files added:
- pkg/linters/httprespbodyclose/httprespbodyclose.go
- pkg/linters/httprespbodyclose/httprespbodyclose_test.go
- pkg/linters/httprespbodyclose/testdata/src/httprespbodyclose/httprespbodyclose.go

Also registers the new analyzer in cmd/linters/main.go.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added automation cookie Issue Monster Loves Cookies! go-linters labels Jul 1, 2026
@pelikhan pelikhan marked this pull request as ready for review July 1, 2026 21:45
Copilot AI review requested due to automatic review settings July 1, 2026 21:45
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ PR Code Quality Reviewer failed during code quality review.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Test Quality Sentinel completed test quality analysis.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a new custom go/analysis linter (httprespbodyclose) to gh-aw’s in-repo linter suite, intended to prevent HTTP response-body leaks by flagging non-deferred resp.Body.Close() calls.

Changes:

  • Introduces pkg/linters/httprespbodyclose analyzer and hooks it into the cmd/linters multichecker.
  • Adds analysistest-based unit tests + testdata fixtures for the new analyzer.
  • Extends the linter runner (cmd/linters/main.go) to include the new analyzer in the default linter set.
Show a summary per file
File Description
pkg/linters/httprespbodyclose/httprespbodyclose.go New analyzer implementation that tracks *http.Response variables and detects non-deferred Body.Close() calls.
pkg/linters/httprespbodyclose/httprespbodyclose_test.go Adds analysistest harness for the new analyzer.
pkg/linters/httprespbodyclose/testdata/src/httprespbodyclose/httprespbodyclose.go Test fixtures for expected diagnostics and non-diagnostic cases.
cmd/linters/main.go Registers httprespbodyclose.Analyzer in the default multichecker set.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 4/4 changed files
  • Comments generated: 3
  • Review effort level: Low

Comment thread cmd/linters/main.go
Comment on lines 74 to 78
fmterrorfnoverbs.Analyzer,
hardcodedfilepath.Analyzer,
httpnoctx.Analyzer,
httprespbodyclose.Analyzer,
httpstatuscode.Analyzer,
Comment thread cmd/linters/main.go
Comment on lines 31 to 35
"github.com/github/gh-aw/pkg/linters/hardcodedfilepath"
"github.com/github/gh-aw/pkg/linters/httpnoctx"
"github.com/github/gh-aw/pkg/linters/httprespbodyclose"
"github.com/github/gh-aw/pkg/linters/httpstatuscode"
"github.com/github/gh-aw/pkg/linters/jsonmarshalignoredeerror"
Comment on lines +29 to +32
// GoodNoClose returns the response to the caller, which is responsible for closing — not flagged.
func GoodNoClose(client *http.Client, req *http.Request) (*http.Response, error) {
return client.Do(req)
}
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 1 test(s): 1 design, 0 implementation, 0 violation(s).

📊 Metrics (1 test)
Metric Value
Analyzed 1 (Go: 1, JS: 0)
✅ Design 1 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 1 (100%)
Duplicate clusters 0
Inflation No
🚨 Violations 0
Test File Classification Issues
TestHTTPRespBodyClose httprespbodyclose_test.go:13 design_test / behavioral_contract None

Notes:

  • Build tag //go:build !integration present on line 1 ✅
  • Uses idiomatic analysistest.Run framework with // want directives in testdata
  • Testdata covers 3 scenarios: flagged direct-close, allowed deferred-close, allowed no-close
  • Production code ratio: 16 test lines / 200 production lines = 0.08 (well under 2:1 threshold)

Verdict

Passed. 0% implementation tests (threshold: 30%). The single test uses the idiomatic Go analysis test framework (analysistest.Run), encoding assertions as // want directives in testdata. It covers the critical positive case (non-deferred close → diagnostic) and two negative cases (deferred close, no close). No violations detected.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • awmgmcpg

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "awmgmcpg"

See Network Configuration for more information.

🧪 Test quality analysis by Test Quality Sentinel · 29.4 AIC · ⌖ 16.9 AIC · ⊞ 6.8K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

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 Quality Sentinel: 100/100. 0% implementation tests (threshold: 30%).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (248 new lines in pkg/ and cmd/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/42816-add-httprespbodyclose-linter.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI couldn't infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-42816: Add httprespbodyclose Custom Go Linter

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 0042-use-postgresql.md for PR #42).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 48.5 AIC · ⌖ 9.59 AIC · ⊞ 8.4K ·
Comment /review to run again

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Skills-Based Review 🧠

Applied /tdd and /codebase-design — requesting changes on test coverage gaps.

📋 Key Themes & Highlights

Key Themes

  • Test coverage gaps: The testdata has only 3 fixtures. The fileclosenotdeferred sibling linter has 9 fixtures covering closures, shadowing, reassignment, error-assigned close, and nolint suppression. All of those edge-case paths exist in this analyzer too and are currently untested.
  • Specific missing cases (see inline comments for details):
    • Closure/FuncLit boundary — ensures the FuncLit guard at line 78 is regression-tested
    • Variable shadowing — inner := re-declaration of resp
    • Response reassignment — the prev violation path in trackHTTPAssignment (lines 148–155)
    • err := resp.Body.Close() (RHS-of-assignment path documented in comment at line 84)
    • //nolint:httprespbodyclose suppression (both code paths in lines 64 and 149)
  • Minor: diagnostic position points to the HTTP call site; consider analysis.Diagnostic.Related to also point at the manual Body.Close() line for better UX.

Positive Highlights

  • ✅ Excellent use of types.Object keying — correctly handles multiple variables and reassignment
  • ✅ Closure boundary (FuncLit guard) is consistent with sibling fileclosenotdeferred linter
  • ✅ Clean structural parity with fileclosenotdeferred makes the code easy to navigate
  • isHTTPResponsePtr is precise and type-safe — no fragile string matching on variable names
  • ✅ Clear PR description with problem statement and evidence of the gap

@copilot please address the review comments above.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 76 AIC · ⌖ 12.8 AIC · ⊞ 6.6K ·
Comment /matt to run again

@github-actions github-actions Bot left a comment

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.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 76 AIC · ⌖ 12.8 AIC · ⊞ 6.6K
Comment /matt to run again

// GoodNoClose returns the response to the caller, which is responsible for closing — not flagged.
func GoodNoClose(client *http.Client, req *http.Request) (*http.Response, error) {
return client.Do(req)
}

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 test case for closure/FuncLit boundary — the implementation explicitly stops traversal at *ast.FuncLit (line 78 of the analyzer) but there is no fixture verifying that a resp.Body.Close() inside a closure does not trigger a diagnostic on the outer response variable.

💡 Suggested test fixture

The analogous fileclosenotdeferred testdata has an OpenInClosure case for exactly this boundary. Add a similar case:

// GoodCloseInsideClosure — resp.Body.Close() inside a closure should not be
// attributed to the outer function's resp variable.
func GoodCloseInsideClosure(client *http.Client, req *http.Request) {
	resp, err := client.Do(req)
	if err != nil {
		return
	}
	go func() { resp.Body.Close() }()
}

Without this test, a regression in the FuncLit guard would go undetected.

@copilot please address this.

// GoodNoClose returns the response to the caller, which is responsible for closing — not flagged.
func GoodNoClose(client *http.Client, req *http.Request) (*http.Response, error) {
return client.Do(req)
}

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 test case for variable shadowing — the analogous fileclosenotdeferred testdata has a ShadowedVar case, but there is none here. A shadowed resp variable (inner block re-declaring with :=) could interact with the types.Object keying in unexpected ways.

💡 Suggested test fixture
// GoodShadowedResp — inner resp shadows outer resp; both are deferred — not flagged.
func GoodShadowedResp(client *http.Client, req1, req2 *http.Request) error {
	resp, err := client.Do(req1)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if true {
		resp, err := client.Do(req2) // shadows outer resp
		if err != nil {
			return err
		}
		defer resp.Body.Close()
		_ = resp
	}
	return nil
}

@copilot please address this.

// GoodNoClose returns the response to the caller, which is responsible for closing — not flagged.
func GoodNoClose(client *http.Client, req *http.Request) (*http.Response, error) {
return client.Do(req)
}

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 test case for response reassignment — the fileclosenotdeferred testdata has ReopenWithManualCloseThenDefer to cover the variable-reuse path in trackHTTPAssignment (lines 148–155 of the analyzer). No equivalent fixture is present here, leaving the "report prior unresolved violation before overwriting" logic untested.

💡 Suggested test fixture
// BadReopenManualCloseThenDefer — first response manually closed (violation),
// second response deferred (ok). First should be flagged.
func BadReopenManualCloseThenDefer(client *http.Client, req1, req2 *http.Request) error {
	resp, err := client.Do(req1) // want `HTTP response Body\.Close\(\) should be deferred.*`
	if err != nil {
		return err
	}
	resp.Body.Close() // manual close — violation for first call
	resp, err = client.Do(req2)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return nil
}

@copilot please address this.

// GoodNoClose returns the response to the caller, which is responsible for closing — not flagged.
func GoodNoClose(client *http.Client, req *http.Request) (*http.Response, error) {
return client.Do(req)
}

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 nolint suppression test cases — the testdata has no fixture for //nolint:httprespbodyclose, despite the analyzer supporting suppression. Both code paths that check nolint.HasDirective (line 64 and line 149) are left untested.

💡 Suggested test fixtures
// SuppressedManualClose — nolint directive suppresses the violation — not flagged.
func SuppressedManualClose(client *http.Client, req *http.Request) ([]byte, error) {
	resp, err := client.Do(req) (nolint/redacted):httprespbodyclose
	if err != nil {
		return nil, err
	}
	data, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	return data, nil
}

Without this, a bug in the nolint wiring would only be caught by the broader integration test suite (if it covers this linter at all).

@copilot please address this.

if !nolint.HasDirective(pass.Fset.PositionFor(state.assignPos, false), noLintLinesByFile) {
pass.Report(analysis.Diagnostic{
Pos: state.assignPos,
Message: "HTTP response Body.Close() should be deferred immediately after receiving the response to prevent resource leaks",

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] Diagnostic reported at the call-site position, not at the non-deferred Body.Close() line — this may be confusing to users. The message says "should be deferred immediately after receiving the response" but the caret in the IDE/CLI points to the HTTP call, not the manual Body.Close().

💡 Discussion

Pointing to the assignment line is reasonable (it's where the resource is acquired), but the existing fileclosenotdeferred analyzer follows the same convention, so this is consistent within the codebase. Worth considering whether a secondary note pointing to the actual Body.Close() line (using analysis.Diagnostic.Related) would improve developer experience, similar to how the Go compiler uses "note" annotations. Not a blocking issue but a UX improvement opportunity.

@copilot please address this.

if obj := bodyCloseReceiver(pass, deferStmt.Call); obj != nil {
if state, found := respVars[obj]; found {
state.hasDeferClose = true
}

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.

[/codebase-design] defer seen before the response is tracked — potential false negative. The inspectNode function processes ast.AssignStmt (line 82) and ast.DeferStmt (line 92) in AST traversal order within the same ast.Inspect walk. For the happy path (resp, err := ... followed by defer resp.Body.Close()) this works correctly because assignments always appear before the defer in source order. However, consider: what happens if code does defer resp.Body.Close() before resp is assigned in the same scope? The respVars[obj] lookup at line 94 will not find the key, silently ignoring the defer. This is an unlikely anti-pattern but worth a comment explaining the ordering assumption.

💡 Suggested improvement

Add a brief inline comment at line 92 clarifying the traversal order assumption:

// defer statements are processed in source order; if a defer appears before
// the assignment (unusual) the state lookup below will be a no-op, which is safe.
if deferStmt, ok := node.(*ast.DeferStmt); ok {

@copilot please address this.


if assign, ok := node.(*ast.AssignStmt); ok {
trackHTTPAssignment(pass, respVars, assign, noLintLinesByFile)
// Also check RHS for manual Body.Close() in assignments like: err := resp.Body.Close()

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 test case for err := resp.Body.Close() — the comment on line 84 says the RHS-of-assignment path handles err := resp.Body.Close(), but the testdata has no fixture exercising it. This path is distinct from the exprStmt path (line 100) and a bug here would go undetected.

💡 Suggested test fixture
// BadManualCloseAssigned — resp.Body.Close() result captured in a variable — also flagged.
func BadManualCloseAssigned(client *http.Client, req *http.Request) ([]byte, error) {
	resp, err := client.Do(req) // want `HTTP response Body\.Close\(\) should be deferred.*`
	if err != nil {
		return nil, err
	}
	data, _ := io.ReadAll(resp.Body)
	err = resp.Body.Close()
	return data, err
}

@copilot please address this.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, address these unresolved items in one pass, and rerun checks afterward:

  • expand the httprespbodyclose analyzer tests to cover the review-noted edge cases (closure/FuncLit boundary, variable shadowing, response reassignment, err := resp.Body.Close(), and //nolint:httprespbodyclose suppression)
  • review and complete the drafted ADR at docs/adr/42816-add-httprespbodyclose-linter.md, then link it in the PR body
  • if helpful, improve the diagnostic UX by pointing related context at the manual Body.Close() site as noted in review

Run: https://github.com/github/gh-aw/actions/runs/28555444682

Generated by 👨‍🍳 PR Sous Chef · 26.8 AIC · ⌖ 15.8 AIC · ⊞ 3.1K ·

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@copilot please run the pr-finisher skill, address these unresolved items in one pass, and rerun checks afterward:

  • expand the httprespbodyclose analyzer tests to cover the review-noted edge case...

Addressed in 9c6d53b.

  • Expanded httprespbodyclose test fixtures for closure/FuncLit boundary, variable shadowing, response reassignment, err := resp.Body.Close() path, and //nolint:httprespbodyclose suppression.
  • Improved diagnostic UX by adding related context pointing to the manual Body.Close() call site.
  • Fixed a real non-deferred close now flagged by the analyzer in pkg/cli/mcp_inspect_mcp_scripts_server.go.
  • Finalized ADR docs/adr/42816-add-httprespbodyclose-linter.md (status set to Accepted).
  • Re-ran local checks: make fmt, make lint, make test-unit, make test, make recompile, and make agent-report-progress.
  • Ran final parallel_validation (Code Review + CodeQL).

I also attempted to update the PR body to add the ADR link, but this environment’s gh pr edit call returned a GitHub GraphQL 403.

Copilot AI requested a review from gh-aw-bot July 2, 2026 00:14
@pelikhan pelikhan merged commit 8a9a610 into main Jul 2, 2026
29 checks passed
@pelikhan pelikhan deleted the linter-miner/httprespbodyclose-1da525c394c9833b branch July 2, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automation cookie Issue Monster Loves Cookies! go-linters

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants