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-actionsgithub-actionsBot 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

AreaChange
pkg/linters/httprespbodyclose/New analyzer package (214 lines)
cmd/linters/main.goRegisters httprespbodyclose.Analyzer in the multichecker
pkg/cli/mcp_inspect_mcp_scripts_server.goFixes pre-existing non-deferred resp.Body.Close() in waitForServerReady
docs/adr/42816-add-httprespbodyclose-linter.mdNew ADR-42816, Status: Accepted
pkg/linters/httprespbodyclose/httprespbodyclose_test.goanalysistest harness
pkg/linters/httprespbodyclose/testdata/src/httprespbodyclose/httprespbodyclose.go9 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
FunctionExpectedReason
BadManualCloseflaggeddirect Body.Close()
GoodDeferClosenot flaggeddefer Body.Close()
GoodNoClosenot flaggedresponse returned to caller
GoodCloseInsideClosurenot flaggedclosure boundary stops descent
GoodShadowedRespnot flaggedboth shadowed vars use defer
BadReopenManualCloseThenDeferfirst assignment flaggedmanual close before reassignment
BadManualCloseAssignedflaggedBody.Close() on assignment RHS
SuppressedOnAssignmentnot flagged//nolint:httprespbodyclose
SuppressedAcrossReassignmentnot flaggednolint 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-actionsgithub-actionsBot 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
CopilotAI review requested due to automatic review settings July 1, 2026 21:45
@github-actions

github-actionsBot commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

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

@github-actions

github-actionsBot commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

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

@github-actions

github-actionsBot commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

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

@github-actions

github-actionsBot commented Jul 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Test Quality Sentinel completed test quality analysis.

CopilotAI 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
FileDescription
pkg/linters/httprespbodyclose/httprespbodyclose.goNew analyzer implementation that tracks *http.Response variables and detects non-deferred Body.Close() calls.
pkg/linters/httprespbodyclose/httprespbodyclose_test.goAdds analysistest harness for the new analyzer.
pkg/linters/httprespbodyclose/testdata/src/httprespbodyclose/httprespbodyclose.goTest fixtures for expected diagnostics and non-diagnostic cases.
cmd/linters/main.goRegisters 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 threadcmd/linters/main.go
Comment on lines 74 to 78
fmterrorfnoverbs.Analyzer,
hardcodedfilepath.Analyzer,
httpnoctx.Analyzer,
httprespbodyclose.Analyzer,
httpstatuscode.Analyzer,
Comment threadcmd/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

Copy link
Copy Markdown
ContributorAuthor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

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

📊 Metrics (1 test)
MetricValue
Analyzed1 (Go: 1, JS: 0)
✅ Design1 (100%)
⚠️ Implementation0 (0%)
Edge/error coverage1 (100%)
Duplicate clusters0
InflationNo
🚨 Violations0
TestFileClassificationIssues
TestHTTPRespBodyClosehttprespbodyclose_test.go:13design_test / behavioral_contractNone

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-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
ContributorAuthor

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

Copy link
Copy Markdown
ContributorAuthor

🏗️ 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

Copy link
Copy Markdown
ContributorAuthor

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-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
ContributorAuthor

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
ContributorAuthor

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.funcGoodCloseInsideClosure(client*http.Client, req*http.Request) {
resp, err:=client.Do(req)
iferr!=nil {
return
}
gofunc() { 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
ContributorAuthor

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.funcGoodShadowedResp(client*http.Client, req1, req2*http.Request) error {
resp, err:=client.Do(req1)
iferr!=nil {
returnerr
}
deferresp.Body.Close()
iftrue {
resp, err:=client.Do(req2) // shadows outer respiferr!=nil {
returnerr
}
deferresp.Body.Close()
_=resp
}
returnnil
}

@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
ContributorAuthor

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.funcBadReopenManualCloseThenDefer(client*http.Client, req1, req2*http.Request) error {
resp, err:=client.Do(req1) // want `HTTP response Body\.Close\(\) should be deferred.*`iferr!=nil {
returnerr
}
resp.Body.Close() // manual close — violation for first callresp, err=client.Do(req2)
iferr!=nil {
returnerr
}
deferresp.Body.Close()
returnnil
}

@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
ContributorAuthor

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.funcSuppressedManualClose(client*http.Client, req*http.Request) ([]byte, error) {
resp, err:=client.Do(req) (nolint/redacted):httprespbodycloseiferr!=nil {
returnnil, err
}
data, _:=io.ReadAll(resp.Body)
resp.Body.Close()
returndata, 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
ContributorAuthor

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
ContributorAuthor

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.ifdeferStmt, 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
ContributorAuthor

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.funcBadManualCloseAssigned(client*http.Client, req*http.Request) ([]byte, error) {
resp, err:=client.Do(req) // want `HTTP response Body\.Close\(\) should be deferred.*`iferr!=nil {
returnnil, err
}
data, _:=io.ReadAll(resp.Body)
err=resp.Body.Close()
returndata, 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>

CopilotAI 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.

CopilotAI requested a review from gh-aw-botJuly 2, 2026 00:14
@pelikhan
pelikhan merged commit 8a9a610 into mainJul 2, 2026
29 checks passed
@pelikhan
pelikhan deleted the linter-miner/httprespbodyclose-1da525c394c9833b branch July 2, 2026 01:03
@github-actions

Copy link
Copy Markdown
ContributorAuthor

🎉 This pull request is included in a new release.

Release: v0.82.2

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automationcookieIssue Monster Loves Cookies!go-linters

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@gh-aw-bot@pelikhan