golangci-lint run --new-from-rev=origin/main
fmt.Errorf(literal) → errors.New(Str*)The root cause is confirmed empirically: staticcheck's SA1006 fires for any non-literal first argument to a printf-style function with no further args — even a same-file const:
main.go:9:9: printf-style function with dynamic format string and no further arguments
should use print-style function instead (SA1006) // fmt.Errorf(StrItemNotFound)
While the pre-rewrite fmt.Errorf("item not found") is clean. So when goconstfix extracts a repeated literal, it must rewrite fmt.Errorf call sites to errors.New — and only when the literal is a plain message (no format verbs), since errors.New does no formatting:
// goconstfix rewriter — call-site classification
func containsVerb(lit string) bool {
for i := 0; i+1 < len(lit); i++ {
if lit[i] != '%' {
continue
}
switch lit[i+1] {
case '%', 'd', 's', 'v', 'q', 'x', 'X', 'o', 'b', 'c', 'f', 'e', 'E', 'g', 'G', 't', 'T', 'U', 'p', 'w':
return true
}
}
return false
}
// extractConst replaces repeated literals with a Str* constant
func extractConst(lit string) string { /* existing goconstfix logic */ }
func rewriteCall(fn string, lit string, argCount int, edits *astutil.Applier) {
switch fn {
case "fmt.Errorf":
if containsVerb(lit) || argCount > 1 {
return // keep fmt.Errorf(StrFmt, args...) — args present, no SA1006; never change format semantics
}
// plain message: fmt.Errorf(StrX) -> errors.New(StrX), import "errors"
edits.Replace(fn, "errors.New")
edits.Replace(lit, extractConst(lit))
case "errors.New", "fmt.Sprintf", "fmt.Fprintf", "log.Printf", "log.Fatalf":
return // never rewrite — these are not affected by SA1006
}
}
Before/after on a touched line:
// before goconstfix (clean)
return fmt.Errorf("item not found")
// naive rewrite (CI regression: SA1006, dynamic format string)
return fmt.Errorf(StrItemNotFound)
// fixed
const StrItemNotFound = "item not found" // added const block
return errors.New(StrItemNotFound) // import "errors" added
Verbs with args stay untouched and lint-clean: fmt.Errorf("user %s not found", name) → fmt.Errorf(StrUserFmt, name) (SA1006 requires no further args; the const is resolvable for arg-count checking).
--new-from-rev=HEAD vs --new-from-rev=origin/mainReproduced with a real git simulation (base commit → goconstfix commit → fix commit):
| Command | Broken state exit | Issues |
|---|---|---|
golangci-lint run --new-from-rev=HEAD |
0 (passes — the trap) | none |
golangci-lint run --new-from-rev=origin/main |
1 (CI fails) | SA1006 + noctx×2 + ineffassign |
Why: --new-from-rev=HEAD diffs against the current HEAD — but the goconstfix commit is already in HEAD, so nothing is "new" and every issue (even the freshly-introduced SA1006) is filtered out. CI diffs against the merge base origin/main, so every line the rewriter touched counts as new: SA1006 shows up as genuinely new, and the pre-existing noctx/ineffassign on those rewritten lines surface too (an unchanged line, e.g. the dead baseQuery, correctly stays hidden).
Fix: replicate CI locally and/or use a merge-base:
# .golangci.yml (CI)
issues:
only-new-issues: true
# local, matches CI exactly
golangci-lint run --new-from-rev=origin/main
# or robust for PRs (equivalent):
golangci-lint run --new-from-rev="$(git merge-base origin/main HEAD)"
// noctx fix: context-aware request
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, StrAPIBase, nil)
if err != nil { return err }
resp, err := http.DefaultClient.Do(req)
// ineffassign fix: drop the dead reassignment
label := StrItemNotFound
_ = label
baseQuery into the final query// before: baseQuery built, overwritten before read -> filters silently dropped
func (s *VecKnnSearch) Search(filters []string) knnQuery {
baseQuery := buildBaseQuery(filters)
baseQuery = StrMatchAll
return knnQuery{base: baseQuery}
}
// after: filters flow into the final query
func (s *VecKnnSearch) Search(filters []string) knnQuery {
baseQuery := buildBaseQuery(filters)
return knnQuery{filters: filters, base: baseQuery}
}
All claims verified empirically in this environment (Go 1.26, staticcheck from `honnef.co/go/tools`, golangci-lint v1.64.8):
1. **SA1006 mechanics** (`/tmp/lintrep`): `fmt.Errorf(StrConst)` with same-file `const` → SA1006 fires; `var` form → fires; literal form → clean. After fix: `errors.New(StrItemNotFound)`, `fmt.Errorf(StrUserFmt, name)` (verb+args), and untouched literals all produce **zero** staticcheck findings (`exit 0`).
2. **Local vs CI divergence** (`/tmp/demo`, 3-commit git repo with `refs/remotes/origin/main` simulated): broken goconstfix commit → `--new-from-rev=HEAD` exit 0, `--new-from-rev=origin/main` exit 1 reporting exactly `SA1006` (new) plus surfaced `noctx`×2 and `ineffassign` on rewritten lines, while the unrewritten `baseQuery` line stayed hidden. After the fix commit → both CI-mode and full lint exit **0**.
3. **Behavioral regression test** (`/tmp/demo`): `TestVecKnnSearchCarriesFilters` fails before the fix (filters dropped, `base` lacks the filter terms) and passes after; `TestSearchNoFilters` covers the empty-filter edge.
4. **Edge cases tested** (unit tests, `/tmp/goconstfix_demo`, 14 subtests):
- plain message → `errors.New`; trailing `%` ("100% done") → not a verb, safe
- verb + args → keep `fmt.Errorf(StrFmt, args...)`
- verb, no args → keep (never worsen pre-existing behavior)
- escaped `%%` → keep (converting would change output text: percent-unescaping)
- `errors.New`/`Sprintf`/`log.*` call sites → never rewritten
Totals: 16 tests (14 rewrite-decision + 2 VecKnnSearch behavioral), all passing.
---{"model": "deepseek-v4-flash", "problem_class": "go-golangci-lint-slice-regression", "result": "passed", "tests": 16}