Skip to content

Make writebytestring SuggestedFix self-contained by adding missing io import edits#44665

Merged
pelikhan merged 7 commits into
mainfrom
copilot/fix-writebytestring-import-issue
Jul 10, 2026
Merged

Make writebytestring SuggestedFix self-contained by adding missing io import edits#44665
pelikhan merged 7 commits into
mainfrom
copilot/fix-writebytestring-import-issue

Conversation

Copilot AI commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

writebytestring rewrote w.Write([]byte(s)) to io.WriteString(w, s) but emitted only the call replacement edit, producing non-compiling fixes (undefined: io) when the file did not already import io. This PR makes the SuggestedFix complete in analyzer-driven fix flows (not editor-assisted import management).

  • SuggestedFix now includes import edits when needed

    • buildFix now appends an io import TextEdit when the target file lacks io.
    • Import insertion is de-duplicated per file to avoid overlapping edits in multi-violation files.
  • Import insertion handles real file shapes

    • Grouped import block: inject io into the existing block.
    • Single ungrouped import: rewrite to grouped form and include io.
    • No import block: insert standalone import "io" after package declaration.
  • Regression coverage for missing-import scenarios

    • Added new writebytestring analysistest fixtures/goldens for:
      • single import without io (with multiple violations in one file),
      • grouped imports without io,
      • no-import files.
// before (no io import)
import "bytes"

func f(buf *bytes.Buffer, s string) {
	buf.Write([]byte(s))
}

// after SuggestedFix
import (
	"bytes"
	"io"
)

func f(buf *bytes.Buffer, s string) {
	io.WriteString(buf, s)
}

Copilot AI and others added 6 commits July 10, 2026 06:43
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix writebytestring autofix to include io import Make writebytestring SuggestedFix self-contained by adding missing io import edits Jul 10, 2026
Copilot AI requested a review from pelikhan July 10, 2026 06:58
@pelikhan pelikhan marked this pull request as ready for review July 10, 2026 06:59
Copilot AI review requested due to automatic review settings July 10, 2026 06:59
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

No test files were added or modified in this PR. Test Quality Sentinel skipped.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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

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

This PR updates the writebytestring analyzer’s autofix so it can add an io import when rewriting w.Write([]byte(s)) to io.WriteString(w, s), and adds analysistest fixtures covering files that previously would not compile after applying the fix.

Changes:

  • Extend buildFix to optionally include an import insertion TextEdit for io.
  • Add addIOImportEdit to handle grouped imports, single ungrouped imports, and no-import files.
  • Add new analysistest fixtures/goldens for missing-io import scenarios.
Show a summary per file
File Description
pkg/linters/writebytestring/writebytestring.go Adds logic to emit an io import edit alongside the call rewrite fix.
pkg/linters/writebytestring/testdata/src/writebytestring/noio_import.go New fixture: single ungrouped import without io and multiple violations.
pkg/linters/writebytestring/testdata/src/writebytestring/noio_import.go.golden Expected output after suggested fixes (rewritten import block including io).
pkg/linters/writebytestring/testdata/src/writebytestring/no_import_block.go New fixture: no imports in file.
pkg/linters/writebytestring/testdata/src/writebytestring/no_import_block.go.golden Expected output after suggested fixes (inserts import "io").
pkg/linters/writebytestring/testdata/src/writebytestring/grouped_noio_import.go New fixture: grouped imports missing io.
pkg/linters/writebytestring/testdata/src/writebytestring/grouped_noio_import.go.golden Expected output after suggested fixes (adds io spec to grouped import).

Review details

Tip

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

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

Comment on lines +246 to +250
for _, imp := range file.Imports {
if imp.Path.Value == `"`+ioPkg+`"` {
return analysis.TextEdit{}, false
}
}
Comment on lines +252 to +264
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.IMPORT || !genDecl.Lparen.IsValid() {
continue
}
// Keep the edit minimal by appending at the end of the grouped import;
// ordering/formatting can be normalized by formatters if desired.
return markAndReturn(analysis.TextEdit{
Pos: genDecl.Rparen,
End: genDecl.Rparen,
NewText: []byte(importSpecIndent + `"` + ioPkg + `"` + "\n"),
})
}
Comment on lines +279 to +287
specText := astutil.NodeText(pass.Fset, singleUngroupedImportDecl.Specs[0])
if specText != "" {
// Rebuild as a grouped import while preserving the existing import spec
// text and adding "io".
return markAndReturn(analysis.TextEdit{
Pos: singleUngroupedImportDecl.Pos(),
End: singleUngroupedImportDecl.End(),
NewText: []byte("import (\n" + importSpecIndent + specText + "\n" + importSpecIndent + `"` + ioPkg + `"` + "\n)"),
})
@pelikhan pelikhan merged commit d67b62e into main Jul 10, 2026
77 of 82 checks passed
@pelikhan pelikhan deleted the copilot/fix-writebytestring-import-issue branch July 10, 2026 07:07

@github-actions github-actions Bot 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.

Review

One blocking correctness issue.

filesWithImportEdit breaks fix independence (line 57 of writebytestring.go)

The shared deduplication map means only the first diagnostic in a file gets a self-contained SuggestedFix. Any subsequent fix applied in isolation leaves the file without an io import, producing a compile error. Since analysis.SuggestedFix is meant to be independently applicable, every fix should carry its own import edit; deduplication (if needed at all) should happen at apply-time, not emit-time.

Minor: unsorted import in golden file

grouped_noio_import.go.golden inserts "io" after "os", which violates standard Go import ordering (goimports would place "io" before "os"). While not a compile error, it will cause goimports to reformat the file on next save.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 18.8 AIC · ⌖ 4.42 AIC · ⊞ 4.8K

@@ -52,6 +57,7 @@ func run(pass *analysis.Pass) (any, error) {
return nil, err

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.

The filesWithImportEdit map is shared across all calls to buildFix, so only the first violation per file gets a self-contained fix that includes the io import edit. If a user applies any subsequent violation's fix in isolation (e.g., as a standalone code action), the file will be left without the io import and will not compile.

Each SuggestedFix is supposed to be independently applicable. Consider including the import edit in every fix and letting the tool chain (gopls / go fix) handle deduplication, rather than skipping it in later fixes.

@copilot please address this.

@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

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

📄 Draft ADR committed: docs/adr/44665-writebytestring-self-contained-io-import-fix.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 could not 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-44665: Make writebytestring SuggestedFix Self-Contained with io Import Edits

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 🏗️ · 53.2 AIC · ⌖ 9.86 AIC · ⊞ 8.5K ·
Comment /review to run again

@github-actions github-actions Bot 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.

Multiple correctness issues in addIOImportEdit

This PR fixes a real bug (non-compiling fixes when io is absent), but addIOImportEdit has edge-case correctness issues that can produce broken fixes.

  1. Multiple ungrouped imports produce broken output (see inline comment on line 278)
  2. file.Name.End() insertion point can corrupt source (see inline comment on line 294)
  3. map[token.Pos]bool key is fragile; use map[*ast.File]bool instead

🔎 Code quality review by PR Code Quality Reviewer · 48.7 AIC · ⌖ 4.67 AIC · ⊞ 5.4K
Comment /review to run again

singleUngroupedImportDecl = genDecl
}
}
if importDeclCount == 1 && singleUngroupedImportDecl != nil {

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.

Multiple ungrouped imports silently fall through to broken fallback: when a file has 2+ separate ungrouped import declarations (e.g. import "bytes" and import "os" on separate lines), importDeclCount > 1 so this branch is skipped, and the code falls through to insert a bare import "io" after the package name — leaving the existing ungrouped imports untouched and producing three separate import blocks that will not compile cleanly.

💡 Suggested fix

Handle the multi-ungrouped case explicitly by converting all existing single-spec ungrouped imports into one grouped block:

if importDeclCount >= 1 && singleUngroupedImportDecl != nil {
    // collect all ungrouped import specs and rewrite into a single grouped block
}

Alternatively, document and test that the linter intentionally only handles the single-ungrouped case and add a comment explaining the fallback behaviour for the multi-ungrouped case so future maintainers don't introduce silent breakage.

}

return markAndReturn(analysis.TextEdit{
Pos: file.Name.End(),

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.

file.Name.End() inserts after the identifier, not the end of the package clause line: ast.Ident.End() returns the position immediately after the last byte of the package name, not after the newline. If the package clause has a trailing comment (e.g. package foo // build tag) or extra whitespace, the inserted \n\nimport "io" is injected into the middle of that comment/line rather than after it.

💡 Suggested fix

Use pass.Fset to find the line-end of the package clause, e.g.:

pkgLine := pass.Fset.Position(file.Package).Line
tokenFile := pass.Fset.File(file.Package)
insertPos := tokenFile.LineStart(pkgLine + 1) // start of the next line

Or, more simply, locate file.Decls[0].Pos() (the first declaration after the package clause) and insert before it.

return analysis.TextEdit{}, false
}

if filesWithImportEdit[file.Pos()] {

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.

Use *ast.File as the map key instead of token.Pos: file.Pos() is an implicit unique key — it works in practice but is fragile in test harnesses or synthetic ASTs where two ast.File nodes could share a starting position. Using the pointer itself as the key (map[*ast.File]bool) eliminates the ambiguity with zero extra cost.

💡 Suggested fix
// in run()
filesWithImportEdit := make(map[*ast.File]bool)

// in addIOImportEdit()
func addIOImportEdit(pass *analysis.Pass, pos token.Pos, filesWithImportEdit map[*ast.File]bool) (analysis.TextEdit, bool) {
    ...
    if filesWithImportEdit[file] { return analysis.TextEdit{}, false }
    markAndReturn := func(edit analysis.TextEdit) (analysis.TextEdit, bool) {
        filesWithImportEdit[file] = true
        return edit, true
    }
    ...
}

@github-actions github-actions Bot 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.

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — requesting changes on one correctness issue.

📋 Key Themes & Highlights

Key Themes

  • SuggestedFix independence violated: The shared filesWithImportEdit map across all fixes means only the first violation's fix includes the io import edit. Applying any other fix in isolation still produces undefined: io. Each analysis.SuggestedFix must be self-contained and independently applicable.
  • Import sort order: The grouped-import insertion appends io at the end of the block rather than maintaining alphabetical order, which diverges from goimports conventions.

Positive Highlights

  • ✅ Solid root-cause fix — moving from "tools handle it" to "fix is self-contained" is the right direction
  • ✅ Good test matrix: three fixture pairs covering each import shape
  • ✅ Clean separation of addIOImportEdit from buildFix
  • ✅ The fallback chain (grouped → ungrouped → no imports) handles real file shapes well

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

NewText: []byte(replacement),
}}
if importEdit, ok := addIOImportEdit(pass, call.Pos(), filesWithImportEdit); ok {
edits = append(edits, importEdit)

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.

[/diagnosing-bugs] The filesWithImportEdit dedup map is shared across all SuggestedFix values, so only the first violation in a file gets the import edit baked in. Each SuggestedFix must be independently applicable — applying fix #2 alone will still produce undefined: io.

💡 Suggested approach

Remove the shared dedup state and include the import edit in every fix unconditionally. Overlapping edits on the same range are safe (they're identical), and the analysistest framework deduplicates them during apply. Alternatively, gate the import edit per-fix via a fresh lookup each time:

// No shared state — check freshly per fix:
func buildFix(pass *analysis.Pass, call *ast.CallExpr, writerArg, sText string) []analysis.SuggestedFix {
    replacement := fmt.Sprintf("io.WriteString(%s, %s)", writerArg, sText)
    edits := []analysis.TextEdit{{Pos: call.Pos(), End: call.End(), NewText: []byte(replacement)}}
    if importEdit, ok := buildIOImportEdit(pass, call.Pos()); ok {
        edits = append(edits, importEdit)
    }
    return []analysis.SuggestedFix{{Message: "Replace with " + replacement, TextEdits: edits}}
}

@copilot please address this.


import (
"bytes"
"os"

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.

[/tdd] The golden file inserts "io" at the end of the import group (after "os"), but goimports/gofmt convention sorts imports alphabetically — "io" should appear before "os". If a formatter runs after applying the fix, the golden won't match exactly.

💡 Suggested golden
import (
	"bytes"
	"io"
	"os"
)

Consider sorting when building the import-insertion text, or document that post-fix formatting is expected.

@copilot please address this.

@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.82.8

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

Labels

None yet

Projects

None yet

3 participants