Skip to content

[linter-miner] feat(linters): add fprintlnsprintf linter#34498

Merged
pelikhan merged 3 commits into
mainfrom
linter-miner/fprintlnsprintf-f83e58bc00ab2cd7
May 24, 2026
Merged

[linter-miner] feat(linters): add fprintlnsprintf linter#34498
pelikhan merged 3 commits into
mainfrom
linter-miner/fprintlnsprintf-f83e58bc00ab2cd7

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot commented May 24, 2026

Summary

Adds a new fprintlnsprintf static-analysis linter that detects the redundant
fmt.Fprintln(w, fmt.Sprintf(...)) pattern and recommends replacing it with the
more efficient fmt.Fprintf(w, format+"\n", args...).

The linter was motivated by a linter-miner run (#17) that surfaced 11+ instances
of this pattern across pkg/cli/trial_support.go (5), pkg/cli/update_merge.go
(3), and pkg/cli/compile_watch.go (3). The pattern is wasteful because
fmt.Sprintf allocates an intermediate string that fmt.Fprintln then wraps
again for the newline — two heap allocations where one suffices.

Note: The 11+ pre-existing violations are not fixed in this PR; this PR
only introduces the linter so they are surfaced on future runs.


What Changed

New linter — pkg/linters/fprintlnsprintf/

File Role
fprintlnsprintf.go Analyzer implementation
fprintlnsprintf_test.go analysistest-based unit test (skipped on integration builds)
testdata/src/fprintlnsprintf/fprintlnsprintf.go Positive and negative fixture cases

Detection logic (syntactic, not type-based):

  • Matches a call expression whose function is the selector fmt.Fprintln.
  • Requires exactly two arguments: a writer and a fmt.Sprintf(...) call.
  • Multi-argument Fprintln calls (3+ args) are intentionally not flagged
    they cannot be trivially collapsed.
  • Aliased fmt imports are not matched (syntactic identifier check only).
  • Test files are skipped to avoid flagging intentional test scaffolding.
  • No suggested-fix / code action is emitted; authors must rewrite manually.

Diagnostic message:

use fmt.Fprintf instead of fmt.Fprintln(w, fmt.Sprintf(...))

Linter registration — cmd/linters/main.go

The fprintlnsprintf analyzer is imported and wired into multichecker.Main,
making it available to all linter runs immediately.

Architecture Decision Record — docs/adr/34498-add-fprintlnsprintf-linter.md

A draft ADR documents:

  • Context: linter-miner run Enhance timestamp and token usage extraction for Codex format in logs #17 findings and the double-allocation problem.
  • Decision: syntactic-only detection scoped to the exact 2-argument form.
  • Alternatives considered: fixing instances manually (rejected — not scalable), third-party linters (rejected — inconsistent with in-house convention), broader multi-pattern linter (rejected — one-analyzer-per-rule convention).
  • Consequences: existing violations will be reported on next run; no auto-remediation in this iteration.

Commit Breakdown

SHA Message
768ff4cdd feat(linters): add fprintlnsprintf linter
0eaab4626 docs(adr): add draft ADR-34498 for fprintlnsprintf linter
213cf5666 fix(linters): avoid multi-arg false positives in fprintlnsprintf

The fix commit tightened the initial implementation to only flag
fmt.Fprintln(w, fmt.Sprintf(...)) with exactly two arguments, preventing
false positives on calls like fmt.Fprintln(w, fmt.Sprintf(...), extraArg).


Test Coverage

The analysistest harness validates:

Fixture case Expected result
fmt.Fprintln(w, fmt.Sprintf("fmt: %v", x)) ✅ Flagged
fmt.Fprintln(w, "plain string") ✅ Not flagged
fmt.Fprintln(w, fmt.Sprintf(...), extraArg) ✅ Not flagged (multi-arg)
fmt.Fprintf(w, "fmt: %v\n", x) ✅ Not flagged (already idiomatic)

Impact Assessment

Area Impact Notes
Existing code ⚠️ Medium 11+ violations will be surfaced on next linter run
Breaking changes ✅ None Linter is additive; no API or behaviour changes
Performance ✅ Positive (long-term) Violations, once fixed, eliminate redundant allocations
Aliased imports ⚠️ Gap import f "fmt" patterns are not caught (documented in ADR)

Generated by PR Description Updater for issue #34498 · sonnet46 1.2M ·

Reports fmt.Fprintln(w, fmt.Sprintf(...)) calls that should be
rewritten as fmt.Fprintf(w, ...) to avoid unnecessary allocations.

Evidence found in:
- pkg/cli/trial_support.go (multiple occurrences)
- pkg/cli/update_merge.go (multiple occurrences)
- pkg/cli/compile_watch.go (multiple occurrences)

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 May 24, 2026
@pelikhan pelikhan marked this pull request as ready for review May 24, 2026 17:59
Copilot AI review requested due to automatic review settings May 24, 2026 17:59
@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 24, 2026

PR Code Quality Reviewer completed the code quality review.

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 24, 2026

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

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 24, 2026

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

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 adds a new custom Go analyzer (fprintlnsprintf) to the gh-aw linter suite to detect redundant fmt.Fprintln(w, fmt.Sprintf(...)) patterns and recommend fmt.Fprintf(w, ...) to avoid intermediate string allocations.

Changes:

  • Implement pkg/linters/fprintlnsprintf analyzer to report fmt.Fprintln(..., fmt.Sprintf(...)) call patterns.
  • Add analysistest-based unit tests and testdata fixtures for the analyzer.
  • Register the new analyzer in the cmd/linters multichecker entrypoint.
Show a summary per file
File Description
pkg/linters/fprintlnsprintf/fprintlnsprintf.go New analyzer implementation that matches fmt.Fprintln whose last arg is fmt.Sprintf.
pkg/linters/fprintlnsprintf/fprintlnsprintf_test.go Adds analysistest runner for the analyzer.
pkg/linters/fprintlnsprintf/testdata/src/fprintlnsprintf/fprintlnsprintf.go Test fixtures with one flagged and two non-flagged examples.
cmd/linters/main.go Registers the new analyzer in the linter driver.

Copilot's findings

Tip

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

  • Files reviewed: 4/4 changed files
  • Comments generated: 2

Comment on lines +37 to +56
// Check if this is fmt.Fprintln with at least 2 args.
if !isFmtFunc(call, "Fprintln") {
return
}
if len(call.Args) < 2 {
return
}

// Skip test files.
pos := pass.Fset.Position(call.Pos())
if filecheck.IsTestFile(pos.Filename) {
return
}

// Check if the last argument is fmt.Sprintf(...).
lastArg, ok := call.Args[len(call.Args)-1].(*ast.CallExpr)
if !ok {
return
}
if !isFmtFunc(lastArg, "Sprintf") {
Comment on lines +37 to +61
// Check if this is fmt.Fprintln with at least 2 args.
if !isFmtFunc(call, "Fprintln") {
return
}
if len(call.Args) < 2 {
return
}

// Skip test files.
pos := pass.Fset.Position(call.Pos())
if filecheck.IsTestFile(pos.Filename) {
return
}

// Check if the last argument is fmt.Sprintf(...).
lastArg, ok := call.Args[len(call.Args)-1].(*ast.CallExpr)
if !ok {
return
}
if !isFmtFunc(lastArg, "Sprintf") {
return
}

pass.Reportf(call.Pos(), "use fmt.Fprintf instead of fmt.Fprintln(w, fmt.Sprintf(...))")
})
Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Good idea for a linter — eliminating the Fprintln+Sprintf double-allocation is a clear win. Two issues need fixing before this is ready.

🔎 Review themes

1. False positive for multi-arg Fprintln (blocking)

fmt.Fprintln(w, "prefix:", fmt.Sprintf(...)) will be flagged even though the transform to fmt.Fprintf is not mechanical (the semantics of multi-arg Fprintln differ). Restrict the match to len(call.Args) == 2. The testdata should also gain a multi-arg not-flagged fixture.

2. Missing nolint support (consistency)

Other linters in the codebase use nolint.BuildLineIndex so callers can suppress with //nolint:fprintlnsprintf. This linter doesn't, making it impossible to suppress on intentional or legacy call-sites.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 1M

if !isFmtFunc(call, "Fprintln") {
return
}
if len(call.Args) < 2 {
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.

False positive when Fprintln has more than two args: the guard len(call.Args) < 2 allows cases like fmt.Fprintln(w, "prefix:", fmt.Sprintf(...)) to be flagged, but there is no mechanical single-step rewrite to fmt.Fprintf — the multi-arg form's concatenation semantics differ. The linter should only fire when there is exactly one variadic argument (len(call.Args) == 2).

💡 Suggested fix

Change the guard from:

if len(call.Args) < 2 {
    return
}

to:

// Only flag the single-arg form: fmt.Fprintln(w, fmt.Sprintf(...)).
// Multi-arg forms cannot be mechanically rewritten to fmt.Fprintf.
if len(call.Args) != 2 {
    return
}

Also add a fixture to the testdata to confirm the multi-arg form is not flagged:

func notFlaggedMultiArg(name string) {
    fmt.Fprintln(os.Stderr, "prefix:", fmt.Sprintf("val=%s", name))
}

return
}

pass.Reportf(call.Pos(), "use fmt.Fprintf instead of fmt.Fprintln(w, fmt.Sprintf(...))")
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.

nolint suppression is not supported: other linters in the codebase (e.g. errstringmatch) use nolint.BuildLineIndex to respect //nolint:fprintlnsprintf comments. Without this, callers cannot suppress the diagnostic on intentional or legacy call-sites.

💡 Suggested addition
import "github.com/github/gh-aw/pkg/linters/internal/nolint"

func run(pass *analysis.Pass) (any, error) {
    insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
    noLintLines := nolint.BuildLineIndex(pass, "fprintlnsprintf")
    ...
    // inside the Preorder callback, before Reportf:
    if noLintLines[pos.Line] {
        return
    }
    pass.Reportf(...)

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 24, 2026

🧪 Test Quality Sentinel completed test quality analysis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor Author

🏗️ Design Decision Gate — ADR Required

This PR introduces a new static-analysis linter package (pkg/linters/fprintlnsprintf/) and registers it in cmd/linters/main.go (>100 new lines in business-logic directories) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/34498-add-fprintlnsprintf-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 and the linter-miner run Enhance timestamp and token usage extraction for Codex format in logs #17 evidence in the PR body.
  2. Complete the missing sections — fill in any Unknown deciders, refine the rationale around why a third in-house linter (after fileclosenotdeferred and manualmutexunlock) is preferred over a generalized analyzer, and confirm the listed alternatives reflect what was actually considered.
  3. Commit the finalized ADR to docs/adr/ on this branch (the draft has already been pushed for you).
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-34498: Add fprintlnsprintf 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., 34498-add-fprintlnsprintf-linter.md for PR #34498).

References: §26368672971

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · opus47 5M ·

Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

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 · sonnet46 1.5M


// Check if this is fmt.Fprintln with at least 2 args.
if !isFmtFunc(call, "Fprintln") {
return
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] False positive risk: len(call.Args) < 2 allows fmt.Fprintln(w, x, fmt.Sprintf(...)) to be flagged, but the suggested fmt.Fprintf rewrite does not trivially apply when there are multiple non-writer arguments.

💡 Fix: tighten the guard to exactly 2 arguments

fmt.Fprintln(w, fmt.Sprintf(...)) is the only form where the rewrite to fmt.Fprintf(w, "format\n", args...) is unambiguous. When there are additional positional args (e.g. fmt.Fprintln(w, prefix, fmt.Sprintf(...))), the replacement is not straightforward and flagging it misleads the developer.

Change the guard to:

if len(call.Args) != 2 {
    return
}

And add a test fixture covering the multi-arg case to confirm it is not flagged:

func notFlagged(name string) {
    fmt.Fprintln(os.Stderr, "prefix", fmt.Sprintf("hello %s", name)) // must NOT be flagged
}

if !ok {
return false
}
ident, ok := sel.X.(*ast.Ident)
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] isFmtFunc uses identifier name matching (ident.Name == "fmt") rather than type-checker resolution, so aliased imports silently bypass the linter.

💡 Known limitation — document or fix

Code like:

import f "fmt"
f.Fprintln(os.Stderr, f.Sprintf("hello %s", name)) // NOT flagged

will be silently missed. This is a known trade-off in name-based linters. Two options:

  1. Document it — add a comment in isFmtFunc noting that aliased imports are out of scope by design (acceptable for an internal codebase where goimports enforces the canonical name).
  2. Fix it — use pass.TypesInfo.Uses[sel.Sel] to resolve the package path instead:
func isFmtFuncTyped(pass *analysis.Pass, call *ast.CallExpr, name string) bool {
    sel, ok := call.Fun.(*ast.SelectorExpr)
    if !ok { return false }
    obj, ok := pass.TypesInfo.Uses[sel.Sel]
    if !ok { return false }
    fn, ok := obj.(*types.Func)
    if !ok { return false }
    return fn.Pkg().Path() == "fmt" && fn.Name() == name
}

Option 1 is perfectly fine for this codebase — just make it explicit.

return
}
if !isFmtFunc(lastArg, "Sprintf") {
return
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.

[/grill-with-docs] The diagnostic message does not include the suggested replacement, making it harder for developers to act on the finding.

💡 Include the suggested rewrite in the message

Other linters in this repo (e.g. fileclosenotdeferred) phrase their messages as actionable directives. Consider:

pass.Reportf(call.Pos(),
    "replace fmt.Fprintln(w, fmt.Sprintf(format, args...)) with fmt.Fprintf(w, format+\"\\n\", args...)")

Or, if you want to show a concrete snippet extracted from the AST, you can use pass.Fset and format.Node to render the Sprintf call, then embed it in the message. At minimum, including "\\n" in the format string hint prevents the common mistake of forgetting the newline when migrating.

)

func flagged(name string) {
fmt.Fprintln(os.Stderr, fmt.Sprintf("hello %s", name)) // want "use fmt.Fprintf"
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] Test fixture is sparse — only one flagged case and two unflagged cases. Adding edge-case fixtures now prevents future regressions when the implementation evolves.

💡 Suggested additional fixture cases
// Should NOT be flagged: multiple positional args — rewrite is ambiguous
func multiArgs(name string) {
    fmt.Fprintln(os.Stderr, "prefix:", fmt.Sprintf("hello %s", name))
}

// Should NOT be flagged: Sprintf is not the last arg
func sprintfNotLast(name string) {
    fmt.Fprintln(os.Stderr, fmt.Sprintf("hello %s", name), "suffix")
}

// Should be flagged: Sprintf with no format args
func noFormatArgs() {
    fmt.Fprintln(os.Stderr, fmt.Sprintf("static")) // want "use fmt.Fprintf"
}

The first two cases are especially important for validating the len(call.Args) != 2 guard suggested in the implementation review.

@github-actions
Copy link
Copy Markdown
Contributor Author

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 1 test: 1 design test, 0 implementation tests, 0 guideline violations.

📊 Metrics & Test Classification (1 test analyzed)
Metric Value
New/modified tests analyzed 1
✅ Design tests (behavioral contracts) 1 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (100%)
Duplicate test clusters 0
Test inflation detected No (test: 15 lines, production: 77 lines, ratio 0.19:1)
🚨 Coding-guideline violations 0

Test Classification Details

Test File Classification Issues Detected
TestAnalyzer pkg/linters/fprintlnsprintf/fprintlnsprintf_test.go:12 ✅ Design None

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 1 test — unit (//go:build !integration)
  • 🟨 JavaScript (*.test.cjs, *.test.js): 0 tests

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%).

TestAnalyzer uses the analysistest.Run framework — the idiomatic pattern for Go static-analysis linters. The testdata file at testdata/src/fprintlnsprintf/fprintlnsprintf.go includes both a positive case (// want "use fmt.Fprintf") and two negative cases (plain string and already-correct fmt.Fprintf), so the test enforces the full behavioral contract of the linter: flag Fprintln(w, Sprintf(...)), leave everything else alone.

No mocks, build tag present (//go:build !integration), no assertion-message gaps (the analysistest framework does not use testify, so the guideline doesn't apply here).

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 1.1M ·

Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

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. Test quality is excellent - 0% of new tests are implementation tests (threshold: 30%).

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot review all comments and reviews and resolve once you are done

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented May 24, 2026

@copilot review all comments and reviews and resolve once you are done

Done. I reviewed the feedback, tightened the analyzer to only match the exact 2-arg fmt.Fprintln(w, fmt.Sprintf(...)) pattern, and added edge-case fixtures for multi-arg Fprintln calls. Addressed in commit 213cf56.

Copilot AI requested a review from pelikhan May 24, 2026 18:30
@pelikhan pelikhan merged commit 05db4d1 into main May 24, 2026
@pelikhan pelikhan deleted the linter-miner/fprintlnsprintf-f83e58bc00ab2cd7 branch May 24, 2026 18:32
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.

3 participants