-
Notifications
You must be signed in to change notification settings - Fork 18.8k
Description
Go version
go version go1.26.0 linux/amd64
Output of go env in your module/workspace:
AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/home/michael/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/michael/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build3478247958=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/home/michael/dcs/go.mod'
GOMODCACHE='/home/michael/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/michael/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/home/michael/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.0.linux-amd64'
GOSUMDB='sum.golang.org'
GOTELEMETRY='on'
GOTELEMETRYDIR='/home/michael/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/home/michael/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.0.linux-amd64/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.26.0'
GOWORK=''
PKG_CONFIG='pkg-config'What did you do?
Ran go fix ./..., which broke my tests
What did you see happen?
I expected my code to be rewritten without changing behavior :)
cc @adonovan
What did you expect to see?
I asked Claude Opus 4.6 to extract a reproducer and have verified that the reproducer illustrates the problem. Allow me to post AI slop, with a hypothesis on where the problem actually is (I have not verified that part):
The pattern
Consider the following Go code, which I found in the Debian Code
Search project's dpkgversion package:
colon := strings.Index(trimmed, ":")
if colon != -1 {
// parse epoch from trimmed[:colon]
result.Epoch = uint(epoch)
}
result.Version = trimmed[colon+1:] // unconditionalThis is a common (if subtle) idiom:
strings.Indexreturns-1when the separator is not found.- When found, the
ifblock processes the part before the separator. - The unconditional
trimmed[colon+1:]works in both cases: when the separator
is found, it gives everything after it. When the separator is absent,
-1 + 1 = 0, sotrimmed[0:]is the entire string.
After running go fix, the code becomes:
before, after, ok := strings.Cut(trimmed, ":")
if ok {
// parse epoch from before
result.Epoch = uint(epoch)
}
result.Version = after // BUG: "" when no colon foundThe problem: when ok is false (no separator), after is "". The original
code returned the whole string, but now it returns an empty string.
Minimal reproducer
I distilled this into a self-contained reproducer:
package main
import (
"fmt"
"strings"
)
func parse(s string) (string, error) {
colon := strings.Index(s, ":")
if colon != -1 {
fmt.Println("has epoch:", s[:colon])
}
version := s[colon+1:]
if len(version) == 0 {
return "", fmt.Errorf("empty version")
}
return version, nil
}
func main() {
v, err := parse("0")
if err != nil {
fmt.Println("error:", err)
} else {
fmt.Printf("version=%q\n", v)
}
}Steps:
$ go run main.go
version="0"
$ go fix ./...
$ go run main.go
error: empty version
The go fix rewrite turns version := s[colon+1:] into version := after,
which is "" when the input has no colon.
Root cause in the analyzer
The bug is in
stringscut.go,
specifically in the checkIdxUses function (line 377).
This function classifies every use of the index variable i into four buckets:
negative— conditions likei < 0ori == -1nonnegative— conditions likei >= 0ori != -1beforeSlice—s[:i]afterSlice—s[i+len(substr):]
If every use of i fits one of these buckets, the rewrite is considered safe
(line 190). All afterSlice occurrences are then replaced with after.
The problem is purely syntactic classification. The function checks that each
use pattern-matches one of the valid forms, but it never checks control
flow. It does not verify that beforeSlice and afterSlice uses are dominated
by a nonnegative guard on the index variable.
In my reproducer, s[colon+1:] on line 15 (outside the if colon != -1 block)
is classified as afterSlice because it syntactically matches s[i + const:]
where const == len(":"). The rewrite proceeds and replaces it with after.
Here's the relevant code path in checkIdxUses:
case edge.SliceExpr_Low, edge.SliceExpr_High:
slice := n.(*ast.SliceExpr)
if sameObject(info, s, slice.X) && slice.Max == nil {
if isBeforeSlice(info, ek, slice) {
beforeSlice = append(beforeSlice, slice)
return true
} else if isAfterSlice(info, ek, slice, substr) {
afterSlice = append(afterSlice, slice)
return true
}
}No dominator check. No control flow analysis. Just syntax matching.
Existing issues
I checked all open and closed stringscut issues on the Go issue tracker as of
today (2026-02-22). None of them cover this specific bug:
- #77566:
:=vs=variable reuse (different) - #77529: OOB panic on
buf.Bytes()(different) - #76687: false positives with
i > 0(different, fixed) - #77563:
stringscutprefixempty prefix semantics (different analyzer)