Skip to content

[linter-miner] linter: add errorfwrapv — flag fmt.Errorf using %v to wrap errors instead of %w#39263

Merged
pelikhan merged 8 commits into
mainfrom
linter-miner/errorfwrapv-a5ceb20cafb406b1
Jun 14, 2026
Merged

[linter-miner] linter: add errorfwrapv — flag fmt.Errorf using %v to wrap errors instead of %w#39263
pelikhan merged 8 commits into
mainfrom
linter-miner/errorfwrapv-a5ceb20cafb406b1

Conversation

@github-actions

@github-actions github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new errorfwrapv custom go/analysis linter that detects fmt.Errorf calls where a %v verb is applied to an error-interface argument. These calls silently swallow the error chain; %w should be used instead to allow callers to inspect wrapped errors via errors.Is / errors.As. The linter ships with suppression support (//nolint:errorfwrapv), test-file skipping, and is documented by a new ADR.


Changes

New files

File Description
pkg/linters/errorfwrapv/errorfwrapv.go Core go/analysis analyzer. Uses types.Implements to identify error-interface arguments paired with %v format verbs inside fmt.Errorf calls. Supports //nolint:errorfwrapv line suppression and skips test files.
pkg/linters/errorfwrapv/ (test fixtures) Bad-case functions (%v with error args) and good-case functions (%w, non-error args, suppressed violations) that drive the analyzer's tests.
docs/adr/39263-custom-linter-for-errorf-percent-v-error-wrapping.md Draft ADR-39263 recording the rationale and design decisions for shipping errorfwrapv as a custom linter.

Modified files

File Description
cmd/linters/main.go Imports and wires errorfwrapv into the multichecker analyzer slice.
pkg/linters/spec_test.go Adds errorfwrapv, httpnoctx, timeafterleak, and timesleepnocontext to the documented-analyzer inventory; updates expected count from 25 → 29.
pkg/workflow/compiler_orchestrator_frontmatter.go Extends the existing //nolint:errorlint directive to also carry //nolint:errorfwrapv, suppressing the intentional fmt.Errorf %v call that deliberately avoids exposing os.PathError details to callers.

Implementation notes

  • Type-system detection — the analyzer relies on types.Implements rather than string matching, ensuring it correctly identifies values that satisfy the error interface regardless of their concrete type.
  • %v vs %w distinction — only fmt.Errorf calls are checked (not fmt.Sprintf etc.); the diagnostic fires specifically when the verb is %v and the corresponding argument implements error.
  • Suppression//nolint:errorfwrapv on the same line silences the diagnostic for deliberate, justified non-wrapping calls (e.g. where exposing the wrapped type would leak internal details).
  • Test-file skipping — the analyzer is disabled in _test.go files to avoid noise from test helpers that intentionally use %v for message formatting.

Commit history

Hash Message
e746b8ab8 linter: add errorfwrapv — flag fmt.Errorf %v wrapping errors instead of %w
3b21d268c docs(adr): add draft ADR-39263 for errorfwrapv linter
ce189fdae fix errorfwrapv parsing and docs
bdda0945f fix: suppress intentional errorfwrapv violation
7db30f89a fix: avoid unchecked error interface assertion
1eafd1af7 refactor: avoid errorfwrapv variable shadowing
551cfee5e fix: return static error for nil error interface
3b971b9ef chore: clarify errorfwrapv init error

Checklist

  • New analyzer registered in multichecker (cmd/linters/main.go)
  • Analyzer inventory test updated (pkg/linters/spec_test.go, count 25 → 29)
  • Test fixtures cover bad cases, good cases, and //nolint suppression
  • Intentional %v call in compiler_orchestrator_frontmatter.go suppressed with rationale
  • ADR drafted (docs/adr/39263-...)
  • ADR status promoted from draft to accepted (post-review)

Generated by PR Description Updater for issue #39263 · 281.2 AIC · ⌖ 14.3 AIC · ⊞ 19.9K ·

…of %w

The errorfwrapv linter detects fmt.Errorf calls that format an error
argument using %v instead of %w. Using %v breaks error-chain
inspection: errors.Is and errors.As cannot unwrap the error, which
causes subtle bugs when callers check for specific error types or
sentinel values.

The analyzer:
- Finds all fmt.Errorf(format, args...) calls
- Parses the format string to map positional verb indices to verb chars
- For each %v verb, checks if the corresponding argument implements error
- Reports a diagnostic if so, skipping test files and nolint directives

Evidence from GitHub Discussions (#39228, #39232) and a real occurrence
in pkg/workflow/compiler_orchestrator_frontmatter.go confirms this
pattern appears in the codebase.

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 Jun 14, 2026
@pelikhan pelikhan marked this pull request as ready for review June 14, 2026 18:17
Copilot AI review requested due to automatic review settings June 14, 2026 18:17
@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

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

@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

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

Adds a new custom Go analysis linter (errorfwrapv) to detect fmt.Errorf calls that format an error argument with %v (breaking error unwrapping) instead of %w.

Changes:

  • Introduces the pkg/linters/errorfwrapv analyzer that inspects fmt.Errorf calls with string-literal formats and reports %v applied to error-typed arguments.
  • Adds analysistest-based coverage and fixtures for expected diagnostics.
  • Registers the new analyzer in cmd/linters/main.go so it runs with the linter driver.
Show a summary per file
File Description
pkg/linters/errorfwrapv/errorfwrapv.go Implements the analyzer and a custom format-string verb parser to map verbs to argument positions.
pkg/linters/errorfwrapv/errorfwrapv_test.go Adds analysistest harness for the new analyzer.
pkg/linters/errorfwrapv/testdata/src/errorfwrapv/errorfwrapv.go Adds fixture cases covering “bad” %v on error and “good” non-error %v / %w usage.
cmd/linters/main.go Registers errorfwrapv.Analyzer in the multichecker 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 thread pkg/linters/errorfwrapv/errorfwrapv.go Outdated
Comment on lines +110 to +114
if s[i] == '[' {
for i < len(s) && s[i] != ']' {
i++
}
i++
Comment thread cmd/linters/main.go
Comment on lines 51 to 58
multichecker.Main(
contextcancelnotdeferred.Analyzer,
ctxbackground.Analyzer,
errormessage.Analyzer,
fprintlnsprintf.Analyzer,
errstringmatch.Analyzer,
errorfwrapv.Analyzer,
execcommandwithoutcontext.Analyzer,
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor Author

🏗️ Design Decision Gate — ADR Required

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

📄 Draft ADR committed: docs/adr/39263-custom-linter-for-errorf-percent-v-error-wrapping.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-39263: Ship a Custom errorfwrapv 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 will 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.

🔒 This gate is blocking: link an ADR in the PR body to unblock merge.

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 79.8 AIC · ⌖ 9.84 AIC · ⊞ 6K ·

@github-actions

Copy link
Copy Markdown
Contributor Author

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 1 test: 1 design (behavioral contract), 0 implementation, 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
🚨 Coding-guideline violations 0 (Go mock libraries / missing build tags / no assertion messages)

Test Classification Details

Test File Classification Issues Detected
TestErrorfWrapV pkg/linters/errorfwrapv/errorfwrapv_test.go:13 ✅ Design None

TestErrorfWrapV uses analysistest.Run() — the idiomatic pattern for go/analysis linter tests. The test exercises the full analyzer pipeline against a testdata package where // want annotations declare expected diagnostics. This is a behavioral contract test: it verifies the analyzer flags the right patterns and stays silent on the right non-patterns.

Testdata coverage (testdata/src/errorfwrapv/errorfwrapv.go):

Case Pattern Expected
BadVWrap %v with error arg Flagged ✅
BadVWrapExtra %v for error + %d for non-error Flagged ✅
GoodWWrap %w for error Not flagged ✅
GoodNonErrorVerb %v for non-error string Not flagged ✅
GoodMixedVerbs %v for string + %w for error Not flagged ✅

Language Support

Tests analyzed:

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

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

📖 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 · 188.6 AIC · ⌖ 12.8 AIC · ⊞ 26.9K ·

@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. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%). The single TestErrorfWrapV function uses analysistest.Run() with testdata covering 2 positive-detection cases and 3 true-negative (no-false-positive) cases. Build tag present, no mock libraries, no test inflation.

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

Skills-Based Review 🧠

Applied /tdd — requesting changes on two correctness bugs in parseFormatVerbs plus a few test coverage gaps.

📋 Key Themes & Highlights

Blocking issues

  • %[n] explicit-index not decoded (errorfwrapv.go:108): the bracket block is skipped without extracting n, so argIdx continues its sequential count. %[2]v with an out-of-order error arg is silently missed (false negative).
  • %*v dynamic width not tracked (errorfwrapv.go:127): * is recorded as the verb rather than consuming one argIdx slot for the width, shifting the argument mapping for all subsequent verbs (false negatives in the presence of dynamic widths).

Test coverage gaps

  • No fixture exercises the //nolint:errorfwrapv suppression path.
  • No fixture covers %[n]v explicit-index format verbs.
  • No fixture tests a concrete *T (pointer receiver) error type.

Positive highlights

  • types.Implements(tv.Type, errorIface) is the correct approach for static-type checking; correctly handles error interface variables and pointer types.
  • ✅ Single-report-per-call design (return after first hit) avoids noisy duplicate diagnostics.
  • %% escape is correctly handled (the if s[i] == '%' { continue } branch consumes it before argIdx can be corrupted).
  • ✅ Real-world evidence in the PR description (the existing nolint on compiler_orchestrator_frontmatter.go:50) confirms the linter targets a genuine pattern.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 250.3 AIC · ⌖ 13.9 AIC · ⊞ 27.9K

Comment thread pkg/linters/errorfwrapv/errorfwrapv.go Outdated
}
i++
if i >= len(s) {
break

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] parseFormatVerbs scans past [n] but never extracts the numeric index — argIdx still increments sequentially. For fmt.Errorf("%[2]v occurred: %[1]s", name, err), the verb %[2]v is recorded at argIdx=0 → callArgIdx=1 = name (not an error), silently missing the real error arg — a false negative.

💡 Suggested fix

Extract the integer between [ and ] and use it (0-based) as the argument index for this verb, and reset the sequential counter:

if s[i] == '[' {
    start := i + 1
    for i < len(s) && s[i] != ']' {
        i++
    }
    n, _ := strconv.Atoi(s[start:i]) // 1-based explicit index
    i++                                // skip ']'
    if i >= len(s) {
        break
    }
    // ... parse flags/width/precision, then:
    verbs[n-1] = rune(s[verbPos]) // use explicit 0-based index
    argIdx = n                    // reset sequential counter per Go fmt spec
    continue
}

Add a test fixture for the explicit-index case:

// BadExplicitIndexV uses a positional index with %v on an error arg.
func BadExplicitIndexV(err error) error {
    return fmt.Errorf("%[1]v: failed", err) // want `fmt\.Errorf formats an error argument with %v`
}

goto width
}
}

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] %*v (width taken from an argument, e.g. fmt.Errorf("%-*v: %v", 10, name, err)) is not handled. The parser exits the width loop on * and records * as the verb, consuming argIdx=0. The actual v verb is then skipped. For the second %v, the code maps argIdx=1 → call.Args[2] = name — missing err at call.Args[3] — false negative.

💡 Suggested fix

Handle * in the width (and precision) slot: treat it as consuming one argument index before reading the verb.

// width:
if i < len(s) && s[i] == '*' {
    argIdx++ // '*' consumes one call.Args slot
    i++
} else {
    for i < len(s) && s[i] >= '0' && s[i] <= '9' {
        i++
    }
}
// precision:
if i < len(s) && s[i] == '.' {
    i++
    if i < len(s) && s[i] == '*' {
        argIdx++
        i++
    } else {
        for i < len(s) && s[i] >= '0' && s[i] <= '9' {
            i++
        }
    }
}

Add a test fixture:

// BadDynamicWidthWrap uses dynamic width and %v for the error.
func BadDynamicWidthWrap(err error) error {
    return fmt.Errorf("%-*v", 10, err) // want `fmt\.Errorf formats an error argument with %v`
}

// GoodMixedVerbs uses %w for the error and %v for a non-error.
func GoodMixedVerbs(name string, err error) error {
return fmt.Errorf("operation %v failed: %w", name, err)
}

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] The nolint suppression path (analyzer lines 79–81) has no fixture. If nolint.HasDirective were accidentally removed or inverted, no test would catch it.

💡 Suggested fixture to add
// SuppressedByNolint should emit no diagnostic due to the directive.
func SuppressedByNolint(err error) error {
	return fmt.Errorf("operation failed: %v", err) (nolint/redacted):errorfwrapv
}


// BadVWrap uses %v to format an error — should be %w.
func BadVWrap(err error) error {
return fmt.Errorf("operation failed: %v", err) // want `fmt\.Errorf formats an error argument with %v`

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] Good that BadVWrapExtra shows %v alongside a non-error arg — that's a useful positive case. Consider also adding a fixture for a concrete error-implementing type passed directly (not via the error interface variable) to confirm types.Implements handles pointer receivers correctly.

💡 Suggested fixture
type myError struct{ msg string }

func (e *myError) Error() string { return e.msg }

// BadConcretePointerWrap passes *myError directly with %v.
func BadConcretePointerWrap(err *myError) error {
	return fmt.Errorf("wrapped: %v", err) // want `fmt\.Errorf formats an error argument with %v`
}

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

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

Copilot AI commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

@copilot run pr-finisher skill

Updated in ce189fd: fixed explicit %[n] and dynamic * argument parsing in errorfwrapv, added coverage for positional, dynamic-width, concrete-error, and nolint cases, and refreshed the linter inventory docs/spec. Local make lint, make test-unit, make test, and make recompile all passed. CI on this HEAD is still unverified and needs a maintainer re-trigger.

Copilot AI requested a review from pelikhan June 14, 2026 19:05
@pelikhan

Copy link
Copy Markdown
Collaborator

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

Copilot AI commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

@copilot fix https://github.com/github/gh-aw/actions/runs/27508968086/job/81306159152#step:9:1

Addressed in bdda094: added the explicit //nolint:errorfwrapv opt-out on the intentionally non-wrapping fmt.Errorf site in pkg/workflow/compiler_orchestrator_frontmatter.go, and revalidated with make golint-custom LINTER_FLAGS='-errorfwrapv' plus go test ./pkg/linters/errorfwrapv.

@pelikhan

Copy link
Copy Markdown
Collaborator

Copilot AI and others added 4 commits June 14, 2026 20:57
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 commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

@copilot lint go https://github.com/github/gh-aw/actions/runs/27511301966/job/81312017890

Addressed in 3b971b9: the lint-go failure was from uncheckedtypeassertion on the package-level error interface lookup in pkg/linters/errorfwrapv/errorfwrapv.go. I replaced it with a guarded helper, added an explicit initialization error path, and revalidated with make golint-custom LINTER_FLAGS='-uncheckedtypeassertion -test=false', go test ./pkg/linters/errorfwrapv, and make agent-report-progress.

@pelikhan pelikhan merged commit 5a19e23 into main Jun 14, 2026
29 checks passed
@pelikhan pelikhan deleted the linter-miner/errorfwrapv-a5ceb20cafb406b1 branch June 14, 2026 21:41
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