blob: 5bd241a73d8b84b3ed1b51bd4bebca9b6baa1d1f [file] [log] [blame] [edit]
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package repro
import (
"context"
_ "embed"
"errors"
"fmt"
"go/format"
"go/scanner"
"go/token"
"log/slog"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/oscar/internal/repo"
)
const goGitRepo = "https://go.googlesource.com/go"
//go:embed runtester.go
var runtester []byte
// GoTester implements [CaseTester] except for the Bisect method,
// for test cases that are written in Go.
type GoTester struct {
lg *slog.Logger
executor Executor
tmpDir string
version int // current Go version
goRepo *repo.Repo
}
// An Executor is used to execute a command.
type Executor interface {
// Execute runs the cmd, with args, in dir.
// It returns the standard output,
// and an error that may be [*os/exec.ExitError].
Execute(ctx context.Context, lg *slog.Logger, dir string, cmd string, args ...string) ([]byte, error)
// LookPath looks up a command in PATH.
// This is like os/exec.LookPath.
LookPath(file string) (string, error)
}
// NewGoTester returns a new [GoTester].
func NewGoTester(ctx context.Context, lg *slog.Logger, executor Executor) (*GoTester, error) {
tmpDir, err := os.MkdirTemp("", "gaby-go-tester")
if err != nil {
return nil, err
}
goRepo, err := repo.Clone(ctx, lg, goGitRepo, executor)
if err != nil {
return nil, err
}
goDir := goRepo.Dir()
versionSource, err := os.ReadFile(filepath.Join(goDir, "src/internal/goversion/goversion.go"))
if err != nil {
return nil, err
}
re := regexp.MustCompile(`(?m)^const Version = (\d+)`)
match := re.FindSubmatch(versionSource)
if len(match) == 0 {
return nil, errors.New("internal/goversion/goversion.go does not contain 'const Version = ...'")
}
version, err := strconv.Atoi(string(match[1]))
if err != nil {
return nil, fmt.Errorf("failed to parse Go version: %v", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "runtester.go"), runtester, 0o444); err != nil {
return nil, err
}
if _, err := executor.Execute(ctx, lg, tmpDir, "go", "build", "runtester.go"); err != nil {
return nil, err
}
testdir := filepath.Join(tmpDir, "testdir")
if err := os.Mkdir(testdir, 0o755); err != nil {
return nil, err
}
if _, err := executor.Execute(ctx, lg, testdir, "go", "mod", "init", "gotester"); err != nil {
return nil, err
}
// Default to go1.20 for now. The reproduction case may
// require a sufficiently new Go version, but we don't have
// a way to detect that yet.
if _, err := executor.Execute(ctx, lg, testdir, "go", "mod", "edit", "-go=1.20"); err != nil {
return nil, err
}
gt := &GoTester{
lg: lg,
executor: executor,
tmpDir: tmpDir,
version: version,
goRepo: goRepo,
}
return gt, nil
}
// Destroy destroys a [GoTester], removing the temporary directory.
func (gt *GoTester) Destroy() {
gt.goRepo.Release()
if err := os.RemoveAll(gt.tmpDir); err != nil {
gt.lg.Error("failed to remove temporary directory", "dir", gt.tmpDir, "err", err)
}
}
// testDir returns the path to the test directory,
// which can be used to run tests.
func (gt *GoTester) testDir() string {
return filepath.Join(gt.tmpDir, "testdir")
}
// repoDir returns the path to the Go repository.
func (gt *GoTester) repoDir() string {
return gt.goRepo.Dir()
}
// runTester returns the path to the runtester executable.
func (gt *GoTester) runTester() string {
return filepath.Join(gt.tmpDir, "runtester")
}
// Clean adds a package declaration and imports to try to
// make a test case runnable. This implements [CaseTester.Clean].
func (gt *GoTester) Clean(ctx context.Context, bodyStr string) (string, error) {
body := []byte(bodyStr)
body, err := gt.addPackage(body)
if err != nil {
return "", err
}
body, err = gt.addImports(ctx, body)
if err != nil {
return "", err
}
return string(body), nil
}
// addPackage adds a package declaration to the start of body if needed.
func (gt *GoTester) addPackage(body []byte) ([]byte, error) {
var s scanner.Scanner
fset := token.NewFileSet()
file := fset.AddFile("t.go", fset.Base(), len(body))
s.Init(file, body, nil, 0)
_, tok, _ := s.Scan()
if tok == token.PACKAGE {
_, tok, lit := s.Scan()
if tok != token.IDENT {
return nil, fmt.Errorf("can't parse: package followed by %s", tok)
}
body = gt.addPrefix(body, lit)
return body, nil
}
// No package declaration. Look for the function names.
first := true
firstFuncName := ""
firstFuncNameExported := false
sawMain := false
for {
var pos token.Pos
if first {
first = false
} else {
pos, tok, _ = s.Scan()
}
if tok == token.ILLEGAL {
return nil, fmt.Errorf("can't parse: invalid token at %s", fset.Position(pos))
}
if tok == token.EOF {
break
}
if tok != token.FUNC {
continue
}
_, tok, lit := s.Scan()
if tok != token.IDENT {
return nil, fmt.Errorf("can't parse: first func followed by %s", tok)
}
if lit == "main" {
sawMain = true
}
firstRune, _ := utf8.DecodeRuneInString(lit)
exported := unicode.IsUpper(firstRune)
if firstFuncName == "" || (exported && !firstFuncNameExported) {
firstFuncName = lit
firstFuncNameExported = exported
}
}
var packageName string
if firstFuncName == "" {
// No functions found; add a package declaration
// and hope for the best.
packageName = "p"
} else if sawMain {
packageName = "main"
} else if strings.HasPrefix(firstFuncName, "Test") {
packageName = "p_test"
} else {
packageName = "p"
}
body = append([]byte("package "+packageName+"\n"), body...)
body = gt.addPrefix(body, packageName)
return body, nil
}
// addPrefix adds a prefix to the body indicating how to run the code,
// based on the package name.
func (gt *GoTester) addPrefix(body []byte, packageName string) []byte {
var cmd string
if packageName == "main" {
cmd = "go run"
} else if strings.HasSuffix(packageName, "_test") {
cmd = "go test"
} else {
cmd = "go build"
}
return append([]byte("//"+cmd+"\n"), body...)
}
// addImports invokes goimports, if available, to add import statements.
// If goimports is not available, addImports just formats body.
func (gt *GoTester) addImports(ctx context.Context, body []byte) ([]byte, error) {
goimports, err := gt.executor.LookPath("goimports")
if err != nil {
return gt.format(body)
}
goFile := filepath.Join(gt.tmpDir, "testdir", "imports.go")
if err := os.WriteFile(goFile, body, 0o666); err != nil {
return nil, err
}
defer os.Remove(goFile)
_, err = gt.executor.Execute(ctx, gt.lg, "", goimports, goFile)
if err != nil {
return nil, err
}
body, err = os.ReadFile(goFile)
if err != nil {
return nil, err
}
return body, nil
}
// format formats body.
func (gt *GoTester) format(body []byte) ([]byte, error) {
formatted, err := format.Source(body)
if err != nil {
return nil, fmt.Errorf("can't format: %v", err)
}
return formatted, nil
}
// CleanVersions tries to make the versions guessed by the LLM valid.
// It implements [CaseTester.CleanVersions].
func (gt *GoTester) CleanVersions(ctx context.Context, pass, fail string) (string, string) {
passOrig, failOrig := pass, fail
fixup := func(v string) string {
// Trim anything after a dash.
// The LLM likes to guess go1.NN-RRRRRRR.
if i := strings.Index(v, "-"); i >= 0 {
v = v[:i]
}
// Turn a version like 1.23 into go1.23.
if strings.HasPrefix(v, "1.") {
v = "go" + v
}
// Turn a version like 1.23.2 into release-branch.go1.23.
major := strings.TrimPrefix(v, "go1.")
major, _, _ = strings.Cut(major, ".")
if majorNum, err := strconv.Atoi(major); err == nil {
if majorNum < gt.version {
v = "release-branch.go1." + major
} else if majorNum == gt.version {
v = "master"
} else {
// If the current version is 1.24,
// and the LLM guesses 1.25,
// that is a pure hallucination.
v = "unknown"
}
}
return v
}
pass = fixup(pass)
fail = fixup(fail)
if fail == "" || fail == "unknown" {
// We don't know the fail version, guess tip.
fail = "master"
}
if pass == "" || pass == "unknown" {
// We don't know the pass version, guess the last release.
pass = "release-branch.go1." + strconv.Itoa(gt.version-1)
}
if pass != passOrig || fail != failOrig {
gt.lg.Debug("cleaned versions", "passOld", passOrig, "passNew", pass, "failOld", failOrig, "failNew", fail)
}
return pass, fail
}
// Try runs a test case at the suggested version.
// It implements [CaseTester.Try].
func (gt *GoTester) Try(ctx context.Context, body, version string) (bool, error) {
if err := gt.goRepo.Checkout(ctx, gt.lg, version, gt.executor); err != nil {
return false, err
}
bodyFile := filepath.Join(gt.tmpDir, "testdir", "body.go")
if err := os.WriteFile(bodyFile, []byte(body), 0o666); err != nil {
return false, err
}
_, err := gt.executor.Execute(ctx, gt.lg, gt.goRepo.Dir(), gt.runTester(), bodyFile)
if err != nil {
return false, nil
}
return true, nil
}