diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bd5501d..5adb6bf 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "godebugdiff-marketplace", - "description": "Marketplace for godebugdiff, which lists the GODEBUG defaults that change when you raise the go line in go.mod.", + "description": "Marketplace for godebugdiff, which lists the GODEBUG defaults that change when you raise the go line in go.mod or go.work.", "owner": { "name": "Giulliano Macedo" }, @@ -8,7 +8,7 @@ { "name": "godebugdiff", "source": "./plugin", - "description": "List the GODEBUG behavior defaults that change when you raise the go line in go.mod, and which binaries they reach." + "description": "List the GODEBUG behavior defaults that change when you raise the go line in go.mod or go.work, and which binaries they reach." } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 3f7168b..be8e85e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Claude Code skill and Go CLI that lists the GODEBUG defaults that change when the go line in go.mod is raised, and which binaries import the affected packages. ## Architecture -`plugin/scripts/godebugdiff.go` holds all the logic in one standard-library file, so `go run` needs no setup. `Diff` copies go.mod and go.sum to a temporary directory for each version, `listAt` runs `go list -modfile -json` offline on each copy, `compare` diffs each binary's `DefaultGODEBUG` and checks its imports, `loadTable` and `loadDocs` read package names and descriptions from `$GOROOT`, and `Print` writes the report. `plugin/skills/checking-go-version-bumps/SKILL.md` tells Claude how to use it. See `docs/architecture.md`. +`plugin/scripts/godebugdiff.go` holds all the logic in one standard-library file, so `go run` needs no setup. `Diff` copies go.mod and go.sum (or go.work and go.work.sum with `--work`) to a temporary directory for each version, `listAt`/`listAtWork` run `go list` offline on each copy, `compare` diffs each binary's `DefaultGODEBUG` and checks its imports, `loadTable` and `loadDocs` read package names and descriptions from `$GOROOT`, and `Print` writes the report. `plugin/skills/checking-go-version-bumps/SKILL.md` tells Claude how to use it. See `docs/architecture.md`. ## Commands - `make test`: gofmt, vet, unit tests and integration tests in `tests/` @@ -11,7 +11,7 @@ Claude Code skill and Go CLI that lists the GODEBUG defaults that change when th - `make demo`: run on the example modules ## Conventions -- Standard library only. The tool runs only `go env`, `go mod edit -json` to read go.mod, `go mod edit -go` on its temporary copies, and `go list`, always with `GOPROXY=off` and `GOTOOLCHAIN=local`. Never edit the user's go.mod, never build or run their code, never use the network. +- Standard library only. The tool runs only `go env`, `go mod edit -json` to read go.mod, `go work edit` to read and copy go.work, `go mod edit -go`/`go work edit -go` on its temporary copies, and `go list`, always with `GOPROXY=off` and `GOTOOLCHAIN=local`. Never edit the user's go.mod or go.work, never build or run their code, never use the network. - The toolchain is the source of truth. Don't add a hand-written table of settings: read them from `go list` and `$GOROOT`. - README examples are real output from the example modules. Regenerate them after changing the output format. diff --git a/README.md b/README.md index 2fdff8f..2f8cc5f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ -# godebugdiff: see what changes before you bump the go line in go.mod +# godebugdiff: see what changes before you bump the go line in go.mod or go.work [![CI](https://github.com/GillMacedo/godebugdiff/actions/workflows/ci.yml/badge.svg)](https://github.com/GillMacedo/godebugdiff/actions/workflows/ci.yml) [![MIT license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) -**A Claude Code skill and Go CLI for Go developers about to raise the `go` directive in go.mod, for example from `go 1.21` to `go 1.24`. It lists the GODEBUG defaults that change, which of your binaries import the package each setting controls, and the `godebug` lines that keep the old behavior. The answers come from your installed Go toolchain, offline, without editing go.mod.** +**A Claude Code skill and Go CLI for Go developers about to raise the `go` directive in go.mod, or the go line in go.work, for example from `go 1.21` to `go 1.24`. It lists the GODEBUG defaults that change, which of your binaries import the package each setting controls, and the `godebug` lines or `//go:debug` directives that keep the old behavior. The answers come from your installed Go toolchain, offline, without editing go.mod or go.work.** -Since Go 1.21, the `go` line in your main module's go.mod sets the GODEBUG defaults your binaries are built with. Raising it switches `net/http` routing, TLS key exchange, x509 parsing, `math/rand.Seed`, GOMAXPROCS in containers and more to the new behavior, with no code change and no compile error. The list is spread across many sections of the [GODEBUG history](https://go.dev/doc/godebug#history). `godebugdiff` asks the toolchain itself for each binary's defaults at both go lines and shows the difference. The skill then has Claude check whether your code uses each changed API. +Since Go 1.21, the `go` line in your main module's go.mod (or in go.work, in workspace mode) sets the GODEBUG defaults your binaries are built with. Raising it switches `net/http` routing, TLS key exchange, x509 parsing, `math/rand.Seed`, GOMAXPROCS in containers and more to the new behavior, with no code change and no compile error. The list is spread across many sections of the [GODEBUG history](https://go.dev/doc/godebug#history). `godebugdiff` asks the toolchain itself for each binary's defaults at both go lines and shows the difference. The skill then has Claude check whether your code uses each changed API. ## What you can ask Claude - "I want to bump go.mod from go 1.21 to 1.24. What behavior changes?" +- "We use a go.work workspace. What changes if we raise its go line?" - "This Dependabot PR raises the go directive. Is anything going to break?" - "After upgrading Go, our ServeMux routes return 404. Why?" - "Which godebug lines do I need to keep the old TLS behavior after the bump?" @@ -74,16 +75,24 @@ A module whose go.mod already pins its defaults with `godebug default=go1.21` st Note: go.mod already pins default=go1.21. Pinned settings don't change with the go line, so they aren't listed. +A two-module workspace reads the go line from go.work with `--work`: + + $ go run ./plugin/scripts --work --to 1.24 examples/workspace + go 1.21 -> 1.24 in workspace (go1.27.3, 2 binaries) + +go.work has no `godebug` lines, so in workspace mode the report lists `//go:debug` directives for each main package instead of a go.mod block. + On a real project: [age](https://github.com/FiloSottile/age) at commit `b74dce4`, from its `go 1.25.0` to 1.27, has 8 binaries. 4 of the 6 changed defaults reach one of them, and `x509sslcertoverrideplatform` and `urlstrictcolons` reach only `cmd/age`. Output from five open-source projects is in [`examples/real/`](examples/real/). ## Usage - godebugdiff [--from VERSION] [--to VERSION] [--tests] [--json] [--fail] [DIR] + godebugdiff [--from VERSION] [--to VERSION] [--tests] [--work] [--json] [--fail] [DIR] -- `DIR` is the module root, the directory with go.mod. It defaults to `.`. +- `DIR` is the module root (the directory with go.mod) or, with `--work`, the workspace root (the directory with go.work). It defaults to `.`. - `--to` defaults to the installed toolchain's version, such as 1.27 for go1.27.3. It can't be newer than the toolchain, because older toolchains don't know about later settings. -- `--from` defaults to the go line in go.mod. Pass the base branch's version to review a change that already raised it. +- `--from` defaults to the go line in go.mod, or in go.work with `--work`. Pass the base branch's version to review a change that already raised it. - `--tests` adds test binaries. A module with no main package, such as a library, is checked through its test binaries automatically. +- `--work` reads the go line from `DIR/go.work` instead of `DIR/go.mod`, and checks every module in the workspace. - `--json` prints the full result. `--fail` exits 1 when a change reaches a package one of your binaries imports. Exit code 2 means bad usage, or the go command couldn't load the module. In CI, on a pull request that changes go.mod: @@ -91,18 +100,25 @@ In CI, on a pull request that changes go.mod: base=$(git show origin/main:go.mod | awk '$1 == "go" { print $2 }') go run github.com/GillMacedo/godebugdiff/plugin/scripts@latest --from "$base" . +For a workspace, read the base go line from go.work and pass `--work`: + + base=$(git show origin/main:go.work | awk '$1 == "go" { print $2 }') + go run github.com/GillMacedo/godebugdiff/plugin/scripts@latest --work --from "$base" . + ## How it works It copies go.mod and go.sum to a temporary directory twice, sets the go line with `go mod edit -go`, and runs `go list -modfile` on each copy with `GOPROXY=off` and `GOTOOLCHAIN=local`. `go list` reports each main package's `DefaultGODEBUG` and its imports, so the result comes from the same code that sets defaults in `go build`. Package names and versions come from the toolchain's own table in `$GOROOT/src/internal/godebugs`, and the one-line descriptions from `$GOROOT/doc/godebug.md`. Existing `godebug` lines in go.mod and `//go:debug` directives are applied by the toolchain, so pinned settings don't show up. +With `--work`, it copies go.work and go.work.sum, sets the go line with `go work edit -go`, and rewrites relative `use` and local `replace` paths to absolute paths in the copy. It then runs `go list` with `GOWORK` pointing at the copy and checks every workspace module. The go line in go.work sets the defaults; `godebug` lines in module go.mod files are ignored in workspace mode. + ## Limits - **Imported isn't the same as used.** A binary that imports `net/http` gets the `httpmuxgo121` change listed even if it never creates a ServeMux. The skill has Claude search the code for the API each setting names. - **Only GODEBUG defaults.** Other changes between Go releases, in the release notes, aren't covered. The language change to per-iteration loop variables in Go 1.22 is noted when you cross it, but not analyzed. - **Only what your toolchain knows.** Settings removed from the toolchain no longer appear, and their new behavior already applies whatever your go line says. Use the newest toolchain you plan to build with. - **Needs the module cache or a vendor directory.** It never downloads. If modules are missing it stops and says to run `go mod download`. -- **Crossing Go 1.17 adds requirements.** If the new go line needs `go mod tidy` changes, it makes them in the temporary copy and says so. Your go.mod isn't touched. -- **go.work is ignored.** In workspace mode the go line in go.work sets the defaults. The output says so when a go.work file is active. +- **Crossing Go 1.17 adds requirements.** In module mode, if the new go line needs `go mod tidy` changes, it makes them in the temporary copy and says so. In workspace mode it stops and asks you to run `go mod tidy`; it never edits module files. +- **Workspace files need `--work`.** Without it, an active go.work is only noted. With `--work`, the go line in go.work sets the defaults and `godebug` lines in module go.mod files are ignored. - **Descriptions need `$GOROOT/doc/godebug.md`.** Toolchains that the go command downloads by itself, through `GOTOOLCHAIN`, don't include `doc/`, so they show only the link for each setting. - Descriptions are the first sentence of the matching paragraph in Go's own docs, and some settings were backported, so a description can name a later release than the one in the "Go 1.N" column. The link goes to the full text. @@ -112,11 +128,11 @@ It copies go.mod and go.sum to a temporary directory twice, sets the go line wit **Is it safe to upgrade the Go toolchain without changing go.mod?** Mostly. With the same go line, a newer toolchain keeps the old defaults for settings it still has. Settings that were removed are the exception. `godebugdiff` covers the go line change. -**How do I keep the old behavior after raising the go line?** Add a `godebug` block to go.mod with the old values. `godebugdiff` prints that block for every change that reaches a binary. These settings are removed in later Go releases, so treat pins as temporary. +**How do I keep the old behavior after raising the go line?** Add a `godebug` block to go.mod with the old values. In a workspace, add `//go:debug` directives to each main package or set `GODEBUG` at run time; go.work has no `godebug` lines. `godebugdiff` prints the right form for every change that reaches a binary. These settings are removed in later Go releases, so treat pins as temporary. **Why did my ServeMux routes start returning 404, or start matching differently, after a Go upgrade?** `httpmuxgo121` changed in Go 1.22. With the old value, patterns like `GET /items/{id}` are literal paths. See `examples/muxcheck`. -**Does my library's go line change GODEBUG for programs that use it?** No. Only the main module's go line counts. Raising a library's go line does force those programs to need that Go version, which raises their go line too. +**Does my library's go line change GODEBUG for programs that use it?** No. Only the main module's go line (or the go.work go line in workspace mode) counts. Raising a library's go line does force those programs to need that Go version, which raises their go line too. **Is it an MCP server?** No. It's a skill with one Go source file, so `go run` is all it needs. @@ -124,12 +140,13 @@ It copies go.mod and go.sum to a temporary directory twice, sets the go line wit - **Example modules in `examples/`** with Go 1.27.3 on Linux arm64. The integration tests in `tests/` check that the settings listed for `examples/shop` match the `DefaultGODEBUG` that `go build` records in each binary (read with `go version -m`), at both go lines. - **A real behavior change:** `examples/muxcheck` built at go 1.21 returns 404 and at go 1.22 returns 200, and `godebugdiff` lists `httpmuxgo121` for it. +- **A workspace:** `examples/workspace` has two modules and a go.work with relative `use` paths. The integration test checks both binaries and that go.work is unchanged. - **Healthy cases stay quiet:** `examples/pinned` with `godebug default=go1.21`, and any module compared at the same version, print "No GODEBUG defaults change." - **Real projects**, to the installed 1.27: hey `5626f79` (go 1.24.0), fzf `26ff936` (go 1.23.0), age `b74dce4` (go 1.25.0, 8 binaries), cobra `adbc881` (a library on go 1.15, which needed the temporary tidy) and rclone `f0cb5dd` (which pins two settings in go.mod). Outputs are in `examples/real/`. A vendored copy of hey gave the same result with an empty module cache, and hey without a vendor directory and with an empty cache stopped with the "run go mod download" message. None of these checked whether the projects use each changed API. - **Go 1.22.12**, a downloaded toolchain: the unit tests and the ServeMux test pass, and the tests that need a newer toolchain skip. On `examples/muxcheck` it also lists `tls10server`, `tlsrsakex` and `tlsunsafeekm`, which Go 1.27 removed, and shows links without descriptions. - **Errors:** a `godebug` line with a removed setting, `--to` newer than the toolchain, and a directory with no go.mod. -It hasn't been tested on Windows or macOS, on toolchains other than 1.22.12 and 1.27.3, with go.work workspaces beyond the note, in a fresh Claude session, or through `go run ...@latest`, which needs the published repo. +It hasn't been tested on Windows, on toolchains other than 1.22.12 and 1.27.3, in a fresh Claude session, or through `go run ...@latest`, which needs the published repo. ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index 097844c..fb1e7d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,6 @@ ## Next - A Markdown output for pull request comments -- Read the go line from go.work in workspace mode, instead of only noting it - Show which `godebug` pins in go.mod are already the default at the new version, so they can be removed ## Later diff --git a/docs/architecture.md b/docs/architecture.md index 667ad11..012cebf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,6 +3,13 @@ go.mod --> two temporary copies, with the go line set by `go mod edit -go` each copy --> `go list -e -modfile=COPY -json ./...` with GOPROXY=off, GOTOOLCHAIN=local, GOWORK=off --> main packages (or test binaries) with DefaultGODEBUG and Deps + + go.work (--work) --> two temporary copies, with the go line set by `go work edit -go` + --> relative `use` and local `replace` paths rewritten to absolute + --> `go list -m -json` to find the workspace modules + --> `go list -e -json MODULE/...` for each module with GOWORK=COPY + --> main packages (or test binaries) with DefaultGODEBUG and Deps + compare --> settings whose value differs, per binary --> one Change per setting, with the binaries that import its package $GOROOT/src/internal/godebugs/table.go --> package and version for each setting $GOROOT/doc/godebug.md --> first sentence of the paragraph that names the setting, and a link to its section @@ -11,7 +18,8 @@ - **Source of truth:** `go list` computes `DefaultGODEBUG` with the same code `go build` uses. It applies the go line, `godebug` lines in go.mod and `//go:debug` directives. The integration tests check the result against `go version -m` on built binaries. - **Reach:** a setting reaches a binary when its package, from the toolchain's table, is in the binary's `Deps`. Settings whose package starts with `cmd/` control the go command and are listed separately. - **Libraries:** a module with no main package is checked through `go list -test`, which gives each test binary's defaults. -- **Errors:** if a binary's imports don't load, results would be incomplete, so the tool stops with exit code 2. If the new go line needs `go mod tidy` changes, which happens when crossing Go 1.17, it retries on a fresh copy with `-mod=mod`, using only the module cache. +- **Errors:** if a binary's imports don't load, results would be incomplete, so the tool stops with exit code 2. If the new go line needs `go mod tidy` changes, which happens when crossing Go 1.17, it retries on a fresh copy with `-mod=mod`, using only the module cache. In workspace mode it stops and asks for `go mod tidy` instead, since it must not edit module files. +- **Workspaces:** `--work` copies go.work and go.work.sum, rewrites relative `use` and local `replace` paths to absolute paths, and sets `GOWORK` to the copy. It lists each workspace module by import path because `./...` from the workspace root matches no module. - **Reading GOROOT:** `table.go` is an internal file and could change shape. If it can't be parsed, changes are still listed, without package names, so they can't be marked as reaching a binary. Key choices: one standard-library Go file run with `go run`, and the toolchain as the only data source. See `decisions/`. diff --git a/evals/evals.json b/evals/evals.json index 4585a74..9ec789c 100644 --- a/evals/evals.json +++ b/evals/evals.json @@ -24,5 +24,14 @@ "Explains that only GODEBUG defaults are covered", "Doesn't claim the upgrade is safe" ] + }, + { + "skills": ["checking-go-version-bumps"], + "query": "We use a go.work workspace. I want to raise its go line from 1.21 to 1.24. What changes?", + "expected_behavior": [ + "Runs godebugdiff.go with --work --to 1.24 on the workspace root", + "Reads the go line from go.work, not a module go.mod", + "Says that workspace pins use //go:debug or GODEBUG because go.work has no godebug lines" + ] } ] diff --git a/examples/README.md b/examples/README.md index 5dd1207..373fa31 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,6 +6,7 @@ All modules here are made up and use only the standard library. - `shop/`: go 1.21, two binaries. `cmd/api` serves HTTP and `cmd/worker` only imports `fmt` and `math/rand`, so most changes reach only the API. `shop-1.21-to-1.24.txt` is its real output, from `make examples`. - `pinned/`: go 1.21 with `godebug default=go1.21`, so raising the go line changes no defaults. This is the healthy case. - `ledgerlib/`: a library with no main package, checked through its test binary. +- `workspace/`: a two-module workspace on go 1.21. `app` serves HTTP and `worker` imports `math/rand`; `go.work` uses relative paths. `godebugdiff --work --to 1.24 examples/workspace` reads the go line from `go.work`. ## real/ diff --git a/examples/workspace/app/go.mod b/examples/workspace/app/go.mod new file mode 100644 index 0000000..808a50d --- /dev/null +++ b/examples/workspace/app/go.mod @@ -0,0 +1,3 @@ +module example.com/workspace/app + +go 1.21 diff --git a/examples/workspace/app/main.go b/examples/workspace/app/main.go new file mode 100644 index 0000000..72c3ce0 --- /dev/null +++ b/examples/workspace/app/main.go @@ -0,0 +1,7 @@ +package main + +import "net/http" + +func main() { + _ = http.NewServeMux() +} diff --git a/examples/workspace/go.work b/examples/workspace/go.work new file mode 100644 index 0000000..804fdc6 --- /dev/null +++ b/examples/workspace/go.work @@ -0,0 +1,6 @@ +go 1.21 + +use ( + ./app + ./worker +) diff --git a/examples/workspace/worker/go.mod b/examples/workspace/worker/go.mod new file mode 100644 index 0000000..d17a1e3 --- /dev/null +++ b/examples/workspace/worker/go.mod @@ -0,0 +1,3 @@ +module example.com/workspace/worker + +go 1.21 diff --git a/examples/workspace/worker/main.go b/examples/workspace/worker/main.go new file mode 100644 index 0000000..15521cc --- /dev/null +++ b/examples/workspace/worker/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "fmt" + "math/rand" +) + +func main() { + fmt.Println(rand.Int()) +} diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index b1d614e..e0e95d8 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "godebugdiff", "version": "0.1.0", - "description": "Before you raise the go line in go.mod, list the GODEBUG behavior defaults that change, which binaries import each affected package, and the godebug lines that keep the old behavior.", + "description": "Before you raise the go line in go.mod or go.work, list the GODEBUG behavior defaults that change, which binaries import each affected package, and the godebug lines or //go:debug directives that keep the old behavior.", "author": { "name": "Giulliano Macedo", "url": "https://github.com/GillMacedo" @@ -9,5 +9,5 @@ "homepage": "https://github.com/GillMacedo/godebugdiff", "repository": "https://github.com/GillMacedo/godebugdiff", "license": "MIT", - "keywords": ["go", "golang", "godebug", "go-mod", "go-upgrade", "go-version", "migration", "backwards-compatibility"] + "keywords": ["go", "golang", "godebug", "go-mod", "go-work", "go-upgrade", "go-version", "migration", "backwards-compatibility"] } diff --git a/plugin/scripts/godebugdiff.go b/plugin/scripts/godebugdiff.go index 9fd898c..ccdda40 100644 --- a/plugin/scripts/godebugdiff.go +++ b/plugin/scripts/godebugdiff.go @@ -2,10 +2,11 @@ // the go line in go.mod, and which of your binaries import the packages // each setting controls. // -// It asks the installed Go toolchain: it copies go.mod to a temporary file -// for each version, runs `go list -modfile` on both with the network off, -// and compares each main package's DefaultGODEBUG. It never edits go.mod, -// never builds or runs your code, and never uses the network. +// It asks the installed Go toolchain: it copies go.mod (or go.work with +// --work) to a temporary file for each version, runs `go list` on both with +// the network off, and compares each main package's DefaultGODEBUG. It never +// edits go.mod or go.work, never builds or runs your code, and never uses the +// network. package main import ( @@ -27,14 +28,19 @@ import ( const version = "0.1.0" +// A go.work file without a go line is treated as Go 1.18 by the go command. +const defaultWorkGo = "1.18" + const usage = `usage: godebugdiff [flags] [DIR] -Lists GODEBUG defaults that change when the go line in DIR/go.mod moves -from --from to --to, and which binaries import the affected packages. +Lists GODEBUG defaults that change when the go line in DIR/go.mod (or +DIR/go.work with --work) moves from --from to --to, and which binaries +import the affected packages. - --from VERSION go line to compare from (default: the one in go.mod) + --from VERSION go line to compare from (default: go.mod or go.work) --to VERSION go line to compare to (default: the installed toolchain) --tests also check test binaries (always on for modules with no main package) + --work read the go line from DIR/go.work (workspace mode) --json print the result as JSON --fail exit 1 when a change reaches a package a binary imports --version print the version @@ -56,6 +62,7 @@ type Binary struct { Godebug map[string]string `json:"-"` Deps map[string]bool `json:"-"` Error string `json:"error,omitempty"` + Main bool `json:"-"` // package belongs to a main module } // Change is one GODEBUG setting whose default differs between the two go lines. @@ -74,6 +81,7 @@ type Change struct { // Result is the full comparison. type Result struct { Module string `json:"module"` + Workspace bool `json:"workspace,omitempty"` Dir string `json:"dir"` Toolchain string `json:"toolchain"` From string `json:"from"` @@ -96,6 +104,7 @@ func run(args []string, stdout, stderr io.Writer) int { from := fs.String("from", "", "") to := fs.String("to", "", "") tests := fs.Bool("tests", false, "") + work := fs.Bool("work", false, "") asJSON := fs.Bool("json", false, "") fail := fs.Bool("fail", false, "") showVersion := fs.Bool("version", false, "") @@ -114,7 +123,7 @@ func run(args []string, stdout, stderr io.Writer) int { if fs.NArg() == 1 { dir = fs.Arg(0) } - res, err := Diff(dir, *from, *to, *tests) + res, err := Diff(dir, *from, *to, *tests, *work) if err != nil { fmt.Fprintln(stderr, "godebugdiff:", err) return 2 @@ -174,26 +183,55 @@ func normalize(v string) string { } // Diff compares the default GODEBUG of every binary in dir between two go lines. -func Diff(dir, from, to string, tests bool) (*Result, error) { +// In workspace mode it reads the go line from dir/go.work instead of dir/go.mod. +func Diff(dir, from, to string, tests, workspace bool) (*Result, error) { abs, err := filepath.Abs(dir) if err != nil { return nil, err } - gomod := filepath.Join(abs, "go.mod") - if _, err := os.Stat(gomod); err != nil { - return nil, fmt.Errorf("no go.mod in %s. Run it from a module root or pass the directory", abs) - } - modInfo, err := readModFile(abs, gomod) - if err != nil { - return nil, err + + var gomod, gowork string + var modInfo *modFile + var workInfo *workFile + if workspace { + gowork = filepath.Join(abs, "go.work") + if _, err := os.Stat(gowork); err != nil { + return nil, fmt.Errorf("no go.work in %s. Run it from a workspace root or pass the directory", abs) + } + workInfo, err = readWorkFile(abs, gowork) + if err != nil { + return nil, err + } + } else { + gomod = filepath.Join(abs, "go.mod") + if _, err := os.Stat(gomod); err != nil { + return nil, fmt.Errorf("no go.mod in %s. Run it from a module root or pass the directory", abs) + } + modInfo, err = readModFile(abs, gomod) + if err != nil { + return nil, err + } } + goroot, toolchain, err := goEnv(abs) if err != nil { return nil, err } - res := &Result{Module: modInfo.Module.Path, Dir: dir, Toolchain: toolchain} + res := &Result{Dir: dir, Toolchain: toolchain, Workspace: workspace} + if workspace { + res.Module = "workspace" + } else { + res.Module = modInfo.Module.Path + } if from == "" { - from = modInfo.Go + if workspace { + from = workInfo.Go + if from == "" { + from = defaultWorkGo + } + } else { + from = modInfo.Go + } } if to == "" { to = strings.TrimPrefix(toolchain, "go") @@ -225,6 +263,9 @@ func Diff(dir, from, to string, tests bool) (*Result, error) { var updated []string list := func(name, v string, tests bool) ([]Binary, error) { + if workspace { + return listAtWork(abs, gowork, filepath.Join(tmp, name), v, tests) + } bins, up, err := listAt(abs, gomod, filepath.Join(tmp, name), v, tests) if err != nil { return nil, fmt.Errorf("with go %s: %v", v, err) @@ -262,27 +303,45 @@ func Diff(dir, from, to string, tests bool) (*Result, error) { res.reachCount++ } } + subject := "This module" + if workspace { + subject = "This workspace" + } if res.TestsOnly { - res.Notes = append(res.Notes, "This module has no main package, so these are its test binaries. A library's go line doesn't set GODEBUG for programs that import it: they use their own go.mod. Raising it does make those programs need Go "+to+" or newer, which raises their go line too.") + res.Notes = append(res.Notes, subject+" has no main package, so these are its test binaries. A library's go line doesn't set GODEBUG for programs that import it: they use their own go.mod. Raising it does make those programs need Go "+to+" or newer, which raises their go line too.") } fromMinor, _ := minor(from) if fromMinor < 22 && toMinor >= 22 { - res.Notes = append(res.Notes, "Crossing Go 1.22 also changes the language: each loop iteration gets its own loop variable in this module's code. That isn't a GODEBUG setting, so it isn't listed above. See https://go.dev/blog/loopvar-preview.") - } - if modInfo.Toolchain != "" { - if tm, err := minor(modInfo.Toolchain); err == nil && tm < toMinor { - res.Notes = append(res.Notes, fmt.Sprintf("go.mod has \"toolchain %s\", which is older than go %s. Go ignores a toolchain line older than the go line, so remove or raise it with the bump.", modInfo.Toolchain, to)) + what := "this module's code" + if workspace { + what = "this workspace's code" } + res.Notes = append(res.Notes, "Crossing Go 1.22 also changes the language: each loop iteration gets its own loop variable in "+what+". That isn't a GODEBUG setting, so it isn't listed above. See https://go.dev/blog/loopvar-preview.") + } + toolchainLine, filename := "", "" + if workspace { + toolchainLine, filename = workInfo.Toolchain, "go.work" + } else { + toolchainLine, filename = modInfo.Toolchain, "go.mod" } - if gw := goworkPath(abs); gw != "" { - res.Notes = append(res.Notes, "A go.work file is active ("+gw+"). In workspace mode, the go line in go.work sets GODEBUG defaults, not go.mod. These results ignore go.work.") + if toolchainLine != "" { + if tm, err := minor(toolchainLine); err == nil && tm < toMinor { + res.Notes = append(res.Notes, fmt.Sprintf("%s has \"toolchain %s\", which is older than go %s. Go ignores a toolchain line older than the go line, so remove or raise it with the bump.", filename, toolchainLine, to)) + } } - if len(modInfo.Godebug) > 0 { - var pins []string - for _, g := range modInfo.Godebug { - pins = append(pins, g.Key+"="+g.Value) + if workspace { + res.Notes = append(res.Notes, "In workspace mode, the go line in go.work sets the GODEBUG defaults; godebug lines in module go.mod files are ignored.") + } else { + if gw := goworkPath(abs); gw != "" { + res.Notes = append(res.Notes, "A go.work file is active ("+gw+"). In workspace mode, the go line in go.work sets GODEBUG defaults, not go.mod. These results ignore go.work.") + } + if len(modInfo.Godebug) > 0 { + var pins []string + for _, g := range modInfo.Godebug { + pins = append(pins, g.Key+"="+g.Value) + } + res.Notes = append(res.Notes, "go.mod already pins "+strings.Join(pins, ", ")+". Pinned settings don't change with the go line, so they aren't listed.") } - res.Notes = append(res.Notes, "go.mod already pins "+strings.Join(pins, ", ")+". Pinned settings don't change with the go line, so they aren't listed.") } return res, nil } @@ -309,6 +368,28 @@ func readModFile(dir, gomod string) (*modFile, error) { return &m, nil } +type workFile struct { + Go string + Toolchain string + Use []struct{ DiskPath string } + Replace []struct { + Old struct{ Path, Version string } + New struct{ Path, Version string } + } +} + +func readWorkFile(dir, gowork string) (*workFile, error) { + out, err := goCmd(dir, "work", "edit", "-json", gowork) + if err != nil { + return nil, err + } + var w workFile + if err := json.Unmarshal(out, &w); err != nil { + return nil, fmt.Errorf("reading go work edit -json: %v", err) + } + return &w, nil +} + func goEnv(dir string) (goroot, version string, err error) { out, err := goCmd(dir, "env", "GOROOT", "GOVERSION") if err != nil { @@ -359,9 +440,18 @@ func keepFlags(goflags string) string { } func goCmd(dir string, args ...string) ([]byte, error) { + return goCmdEnv(dir, baseEnv(true), args...) +} + +// goCmdWork runs the go command with GOWORK pointing at work. +func goCmdWork(dir, work string, args ...string) ([]byte, error) { + return goCmdEnv(dir, append(baseEnv(false), "GOWORK="+work), args...) +} + +func goCmdEnv(dir string, env []string, args ...string) ([]byte, error) { cmd := exec.Command("go", args...) cmd.Dir = dir - cmd.Env = baseEnv(true) + cmd.Env = env var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() @@ -424,6 +514,127 @@ func copyMod(dir, gomod, tmpBase, goVersion string) (string, error) { return mod, nil } +// copyWork copies go.work and go.work.sum, sets the go line, and rewrites +// relative use and local replace paths to absolute paths, because the copy +// lives outside the workspace. +func copyWork(dir, gowork, tmpBase, goVersion string) (string, error) { + data, err := os.ReadFile(gowork) + if err != nil { + return "", err + } + work := tmpBase + ".work" + if err := os.WriteFile(work, data, 0o600); err != nil { + return "", err + } + if _, err := goCmd(dir, "work", "edit", "-go="+goVersion, work); err != nil { + return "", err + } + info, err := readWorkFile(dir, gowork) + if err != nil { + return "", err + } + for _, u := range info.Use { + if u.DiskPath == "" || filepath.IsAbs(u.DiskPath) { + continue + } + abs := filepath.Join(dir, u.DiskPath) + if _, err := goCmd(dir, "work", "edit", "-dropuse="+u.DiskPath, work); err != nil { + return "", err + } + if _, err := goCmd(dir, "work", "edit", "-use="+abs, work); err != nil { + return "", err + } + } + for _, r := range info.Replace { + if r.New.Version != "" || r.New.Path == "" || filepath.IsAbs(r.New.Path) { + continue + } + old := r.Old.Path + if r.Old.Version != "" { + old += "@" + r.Old.Version + } + if _, err := goCmd(dir, "work", "edit", "-replace="+old+"="+filepath.Join(dir, r.New.Path), work); err != nil { + return "", err + } + } + sum, err := os.ReadFile(filepath.Join(dir, "go.work.sum")) + if err != nil { + sum = nil + } + if err := os.WriteFile(tmpBase+".work.sum", sum, 0o600); err != nil { + return "", err + } + return work, nil +} + +// workModules returns the import path of each module in the workspace. +func workModules(dir, work string) ([]string, error) { + out, err := goCmdWork(dir, work, "list", "-m", "-json") + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(out)) + var mods []string + for { + var m struct { + Path string + Main bool + } + if err := dec.Decode(&m); err == io.EOF { + break + } else if err != nil { + return nil, fmt.Errorf("reading go list -m output: %v", err) + } + if m.Main && m.Path != "" { + mods = append(mods, m.Path) + } + } + return mods, nil +} + +// listAtWork copies go.work, sets the go line, and lists the binaries in every +// workspace module. +func listAtWork(dir, gowork, tmpBase, goVersion string, tests bool) ([]Binary, error) { + work, err := copyWork(dir, gowork, tmpBase, goVersion) + if err != nil { + return nil, err + } + mods, err := workModules(dir, work) + if err != nil { + return nil, cleanErr(err) + } + seen := map[string]Binary{} + for _, mod := range mods { + args := []string{"list", "-e", "-json=ImportPath,Name,DefaultGODEBUG,Deps,Error,DepsErrors,ForTest,Module"} + if tests { + args = append(args, "-test") + } + args = append(args, mod+"/...") + out, err := goCmdWork(dir, work, args...) + if err != nil { + if strings.Contains(err.Error(), "updates to go.mod needed") { + return nil, fmt.Errorf("the go command needs to update a module's go.mod; run `go mod tidy` in that module first. godebugdiff never edits module files: %v", err) + } + return nil, cleanErr(err) + } + bins, err := parseList(out, tests) + if err != nil { + return nil, err + } + for _, b := range bins { + if b.Main { + seen[b.ImportPath] = b + } + } + } + bins := make([]Binary, 0, len(seen)) + for _, b := range seen { + bins = append(bins, b) + } + sort.Slice(bins, func(i, j int) bool { return bins[i].ImportPath < bins[j].ImportPath }) + return bins, nil +} + func listArgs(mod string, tests, update bool) []string { args := []string{"list", "-e", "-modfile=" + mod, "-json=ImportPath,Name,DefaultGODEBUG,Deps,Error,DepsErrors,ForTest"} if update { @@ -435,11 +646,15 @@ func listArgs(mod string, tests, update bool) []string { return append(args, "./...") } -var tmpModRE = regexp.MustCompile(`[^\s:]*godebugdiff[0-9]+[/\\](?:from|to)\.mod`) +var ( + tmpModRE = regexp.MustCompile(`[^\s:]*godebugdiff[0-9]+[/\\](?:from|to)\.mod`) + tmpWorkRE = regexp.MustCompile(`[^\s:]*godebugdiff[0-9]+[/\\](?:from|to)\.work(?:\.sum)?`) +) -// cleanErr replaces the temporary go.mod path in toolchain errors. +// cleanErr replaces temporary go.mod and go.work paths in toolchain errors. func cleanErr(err error) error { msg := tmpModRE.ReplaceAllString(err.Error(), "go.mod") + msg = tmpWorkRE.ReplaceAllString(msg, "go.work") if strings.Contains(msg, "GOPROXY=off") || strings.Contains(msg, "module lookup disabled") { msg += "\nSome modules aren't in the module cache. Run `go mod download` first. godebugdiff never downloads." } @@ -452,6 +667,7 @@ type listPkg struct { DefaultGODEBUG string Deps []string ForTest string + Module *struct{ Main bool } Error *struct{ Err string } DepsErrors []struct{ Err string } } @@ -476,7 +692,7 @@ func parseList(out []byte, tests bool) ([]Binary, error) { if isTest && !tests { continue } - b := Binary{ImportPath: p.ImportPath, Godebug: parseGodebug(p.DefaultGODEBUG), Deps: map[string]bool{}} + b := Binary{ImportPath: p.ImportPath, Godebug: parseGodebug(p.DefaultGODEBUG), Deps: map[string]bool{}, Main: p.Module != nil && p.Module.Main} for _, d := range p.Deps { b.Deps[d] = true } @@ -752,7 +968,11 @@ func Print(w io.Writer, r *Result) { if len(r.Binaries) == 1 { count = strings.TrimSuffix(kind, "aries") + "ary" } - fmt.Fprintf(w, "go %s -> %s in %s (%s, %d %s)\n", r.From, r.To, r.Module, r.Toolchain, len(r.Binaries), count) + where := r.Module + if r.Workspace { + where = "workspace" + } + fmt.Fprintf(w, "go %s -> %s in %s (%s, %d %s)\n", r.From, r.To, where, r.Toolchain, len(r.Binaries), count) if len(r.Binaries) == 0 { fmt.Fprintf(w, "\nNo main packages or tests found, so there's nothing to compare.\n") printNotes(w, r) @@ -807,18 +1027,31 @@ func Print(w io.Writer, r *Result) { fmt.Fprintf(w, " %s (%s)\n", c.Name, pkg) } } + pinChanges := reached + if !r.Workspace { + // go.work has no godebug lines, and //go:debug only affects binaries, + // so a workspace can't pin settings that control the go command. + pinChanges = append(pinChanges, gocmd...) + } var pins []string - for _, c := range append(reached, gocmd...) { + for _, c := range pinChanges { if c.Before != "" && c.After == "" { pins = append(pins, c.Name+"="+c.Before) } } if len(pins) > 0 { - fmt.Fprintf(w, "\nTo keep the old behavior of a setting after the bump, pin it in go.mod:\n\n godebug (\n") - for _, p := range pins { - fmt.Fprintf(w, " %s\n", p) + if r.Workspace { + fmt.Fprintf(w, "\nTo keep the old behavior of a setting after the bump, add its //go:debug directive to each main package that needs it, or set GODEBUG at run time:\n\n") + for _, p := range pins { + fmt.Fprintf(w, " //go:debug %s\n", p) + } + } else { + fmt.Fprintf(w, "\nTo keep the old behavior of a setting after the bump, pin it in go.mod:\n\n godebug (\n") + for _, p := range pins { + fmt.Fprintf(w, " %s\n", p) + } + fmt.Fprintf(w, " )\n") } - fmt.Fprintf(w, " )\n") } printNotes(w, r) } diff --git a/plugin/scripts/godebugdiff_test.go b/plugin/scripts/godebugdiff_test.go index ef93409..8680586 100644 --- a/plugin/scripts/godebugdiff_test.go +++ b/plugin/scripts/godebugdiff_test.go @@ -188,6 +188,10 @@ func TestRunErrors(t *testing.T) { t.Errorf("no go.mod: code %d, stderr %q", code, errb.String()) } errb.Reset() + if code := run([]string{"--work", t.TempDir()}, &out, &errb); code != 2 || !strings.Contains(errb.String(), "no go.work") { + t.Errorf("no go.work: code %d, stderr %q", code, errb.String()) + } + errb.Reset() if code := run([]string{"a", "b"}, &out, &errb); code != 2 { t.Errorf("two dirs: code %d", code) } @@ -205,7 +209,7 @@ func TestDiffOnTempModule(t *testing.T) { dir := t.TempDir() write(t, filepath.Join(dir, "go.mod"), "module example.com/tmp\n\ngo 1.21\n") write(t, filepath.Join(dir, "main.go"), "package main\n\nimport \"net/http\"\n\nfunc main() { _ = http.NewServeMux() }\n") - res, err := Diff(dir, "", "1.22", false) + res, err := Diff(dir, "", "1.22", false, false) if err != nil { t.Fatal(err) } @@ -219,10 +223,10 @@ func TestDiffOnTempModule(t *testing.T) { if !reflect.DeepEqual(names, []string{"httplaxcontentlength", "httpmuxgo121"}) { t.Errorf("1.21 -> 1.22 net/http changes = %v", names) } - if _, err := Diff(dir, "", "1.99", false); err == nil || !strings.Contains(err.Error(), "newer than the installed toolchain") { + if _, err := Diff(dir, "", "1.99", false, false); err == nil || !strings.Contains(err.Error(), "newer than the installed toolchain") { t.Errorf("want toolchain error, got %v", err) } - if _, err := Diff(dir, "banana", "", false); err == nil { + if _, err := Diff(dir, "banana", "", false, false); err == nil { t.Error("want error for --from banana") } data, _ := os.ReadFile(filepath.Join(dir, "go.mod")) @@ -231,6 +235,162 @@ func TestDiffOnTempModule(t *testing.T) { } } +func TestReadWorkFile(t *testing.T) { + dir := t.TempDir() + for _, m := range []string{"a", "b", "local"} { + if err := os.Mkdir(filepath.Join(dir, m), 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(dir, m, "go.mod"), "module example.com/"+m+"\n\ngo 1.21\n") + } + write(t, filepath.Join(dir, "go.work"), "go 1.21\n\nuse (\n\t./a\n\t./b\n)\n\nreplace example.com/local => ./local\n") + + w, err := readWorkFile(dir, filepath.Join(dir, "go.work")) + if err != nil { + t.Fatal(err) + } + if w.Go != "1.21" || len(w.Use) != 2 || w.Use[0].DiskPath != "./a" || w.Use[1].DiskPath != "./b" { + t.Errorf("workFile = %+v", w) + } + if len(w.Replace) != 1 || w.Replace[0].Old.Path != "example.com/local" || w.Replace[0].New.Path != "./local" { + t.Errorf("replace = %+v", w.Replace) + } +} + +func TestCopyWork(t *testing.T) { + dir := t.TempDir() + for _, m := range []string{"a", "b", "local"} { + if err := os.Mkdir(filepath.Join(dir, m), 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(dir, m, "go.mod"), "module example.com/"+m+"\n\ngo 1.21\n") + } + original := "go 1.21\n\nuse (\n\t./a\n\t./b\n)\n\nreplace example.com/local => ./local\n" + write(t, filepath.Join(dir, "go.work"), original) + write(t, filepath.Join(dir, "go.work.sum"), "example.com/local v0.0.0 h1:test\n") + + tmp := t.TempDir() + copyPath, err := copyWork(dir, filepath.Join(dir, "go.work"), filepath.Join(tmp, "from"), "1.22") + if err != nil { + t.Fatal(err) + } + got, err := readWorkFile(tmp, copyPath) + if err != nil { + t.Fatal(err) + } + if got.Go != "1.22" { + t.Errorf("go line = %q", got.Go) + } + for i, want := range []string{filepath.Join(dir, "a"), filepath.Join(dir, "b")} { + if i >= len(got.Use) || got.Use[i].DiskPath != want { + t.Errorf("use[%d] = %+v, want %q", i, got.Use, want) + } + } + if len(got.Replace) != 1 || got.Replace[0].New.Path != filepath.Join(dir, "local") { + t.Errorf("replace = %+v", got.Replace) + } + if data, err := os.ReadFile(filepath.Join(tmp, "from.work.sum")); err != nil || string(data) != "example.com/local v0.0.0 h1:test\n" { + t.Errorf("go.work.sum = %q, %v", data, err) + } + if data, _ := os.ReadFile(filepath.Join(dir, "go.work")); string(data) != original { + t.Errorf("original go.work changed: %q", data) + } +} + +func TestDiffWorkspace(t *testing.T) { + dir := t.TempDir() + for _, m := range []string{"app", "worker"} { + if err := os.Mkdir(filepath.Join(dir, m), 0o755); err != nil { + t.Fatal(err) + } + } + write(t, filepath.Join(dir, "app", "go.mod"), "module example.com/ws/app\n\ngo 1.21\n") + write(t, filepath.Join(dir, "app", "main.go"), "package main\n\nimport \"net/http\"\n\nfunc main() { _ = http.NewServeMux() }\n") + write(t, filepath.Join(dir, "worker", "go.mod"), "module example.com/ws/worker\n\ngo 1.21\n") + write(t, filepath.Join(dir, "worker", "main.go"), "package main\n\nimport (\n\t\"fmt\"\n\t\"math/rand\"\n)\n\nfunc main() { fmt.Println(rand.Int()) }\n") + work := "go 1.21\n\nuse (\n\t./app\n\t./worker\n)\n" + write(t, filepath.Join(dir, "go.work"), work) + + res, err := Diff(dir, "", "1.22", false, true) + if err != nil { + t.Fatal(err) + } + if !res.Workspace || res.Module != "workspace" || res.From != "1.21" || res.To != "1.22" { + t.Fatalf("result = %+v", res) + } + want := []string{"example.com/ws/app", "example.com/ws/worker"} + if !reflect.DeepEqual(res.Binaries, want) { + t.Errorf("binaries = %v, want %v", res.Binaries, want) + } + idx := map[string]Change{} + for _, c := range res.Changes { + idx[c.Name] = c + } + if c, ok := idx["httpmuxgo121"]; !ok || !reflect.DeepEqual(c.ReachedBy, []string{"example.com/ws/app"}) { + t.Errorf("httpmuxgo121 = %+v", c) + } + if !strings.Contains(strings.Join(res.Notes, " "), "go line in go.work") { + t.Errorf("notes = %v", res.Notes) + } + if data, _ := os.ReadFile(filepath.Join(dir, "go.work")); string(data) != work { + t.Errorf("go.work changed: %q", data) + } + if _, err := Diff(dir, "", "1.22", false, false); err == nil || !strings.Contains(err.Error(), "no go.mod") { + t.Errorf("without --work: %v", err) + } +} + +func TestDiffWorkspaceUsesTests(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "lib"), 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(dir, "lib", "go.mod"), "module example.com/lib\n\ngo 1.21\n") + write(t, filepath.Join(dir, "lib", "lib.go"), "package lib\n\nfunc F() int { return 1 }\n") + write(t, filepath.Join(dir, "lib", "lib_test.go"), "package lib\n\nimport \"testing\"\n\nfunc TestF(t *testing.T) { _ = F() }\n") + write(t, filepath.Join(dir, "go.work"), "go 1.21\n\nuse ./lib\n") + + res, err := Diff(dir, "", "1.22", false, true) + if err != nil { + t.Fatal(err) + } + if !res.TestsOnly || !reflect.DeepEqual(res.Binaries, []string{"example.com/lib.test"}) { + t.Fatalf("library workspace: %+v", res) + } +} + +func TestPrintWorkspacePins(t *testing.T) { + var buf bytes.Buffer + Print(&buf, &Result{ + Module: "workspace", + Workspace: true, + From: "1.21", + To: "1.22", + Toolchain: "go1.27.3", + Binaries: []string{"example.com/app"}, + Changes: []Change{ + { + Name: "httpmuxgo121", Package: "net/http", Changed: 22, + Before: "1", After: "", ReachedBy: []string{"example.com/app"}, + }, + { + Name: "gotestjsonbuildtext", Package: "cmd/go", Changed: 24, + Before: "1", After: "", GoCommand: true, + }, + }, + }) + s := buf.String() + if !strings.Contains(s, "go 1.21 -> 1.22 in workspace") { + t.Errorf("header:\n%s", s) + } + if !strings.Contains(s, "//go:debug httpmuxgo121=1") || strings.Contains(s, "godebug (") { + t.Errorf("pin advice:\n%s", s) + } + if strings.Contains(s, "//go:debug gotestjsonbuildtext=1") { + t.Errorf("go-command setting should not be pinned with //go:debug:\n%s", s) + } +} + func write(t *testing.T, path, s string) { t.Helper() if err := os.WriteFile(path, []byte(s), 0o644); err != nil { diff --git a/plugin/skills/checking-go-version-bumps/SKILL.md b/plugin/skills/checking-go-version-bumps/SKILL.md index 72cd84f..2ce318b 100644 --- a/plugin/skills/checking-go-version-bumps/SKILL.md +++ b/plugin/skills/checking-go-version-bumps/SKILL.md @@ -1,11 +1,11 @@ --- name: checking-go-version-bumps -description: Lists the GODEBUG behavior defaults that change when the go line in go.mod is raised (for example go 1.21 to go 1.24), which binaries import each affected package, and the godebug lines that keep the old behavior. Use when the user wants to bump the go directive or Go version in go.mod, reviews a PR or Dependabot/Renovate change that raises it, asks what breaks or changes when upgrading Go, or sees a behavior change (ServeMux routing, TLS, x509, GOMAXPROCS) after a Go upgrade. +description: Lists the GODEBUG behavior defaults that change when the go line in go.mod or go.work is raised (for example go 1.21 to go 1.24), which binaries import each affected package, and the godebug lines or //go:debug directives that keep the old behavior. Use when the user wants to bump the go directive or Go version in go.mod, reviews a PR or Dependabot/Renovate change that raises it, asks what breaks or changes when upgrading Go, or sees a behavior change (ServeMux routing, TLS, x509, GOMAXPROCS) after a Go upgrade. --- # Checking a Go version bump -Raising the `go` line in go.mod changes more than the language version. Each main package gets the GODEBUG defaults of the new version, so behavior in `net/http`, `crypto/tls`, `crypto/x509`, `net/url`, `runtime` and others can change with no code change. The bundled tool asks the installed Go toolchain which defaults change and which binaries import the affected packages. It needs Go installed, reads go.mod without editing it, never builds or runs the user's code, and never uses the network. +Raising the `go` line in go.mod changes more than the language version. Each main package gets the GODEBUG defaults of the new version, so behavior in `net/http`, `crypto/tls`, `crypto/x509`, `net/url`, `runtime` and others can change with no code change. The bundled tool asks the installed Go toolchain which defaults change and which binaries import the affected packages. It needs Go installed, reads go.mod or go.work without editing it, never builds or runs the user's code, and never uses the network. ## Run the tool @@ -16,6 +16,7 @@ From the module root, or pass its directory: - `--to` defaults to the installed toolchain's version and can't be newer than it. If the user wants a newer target, say they need to install that Go version first. - `--from` defaults to the current go line. To review a PR that already bumped it, pass the base branch's version: `--from 1.21`. - Add `--json` for structured output, and `--tests` to include test binaries. A module with no main package is checked through its test binaries automatically. +- If the project uses a workspace (a `go.work` file), pass `--work` and the workspace root. The go line in go.work sets the defaults; `godebug` lines in module go.mod files are ignored in workspace mode. - If it says modules aren't in the module cache, ask before running `go mod download`, since that uses the network. ## Check each change that reaches a binary @@ -31,11 +32,11 @@ The tool lists settings by the package that reads them. Importing a package does ## Report 1. A short table: setting, what changes, which binaries, and whether the code uses that path. -2. Recommend either taking the new default or pinning the old one. Only suggest pinning for settings the code really depends on, and use the tool's `godebug ( ... )` block. Pins are meant to be temporary: say that they're removed in later Go releases. +2. Recommend either taking the new default or pinning the old one. Only suggest pinning for settings the code really depends on. In a module, use the tool's `godebug ( ... )` block; in a workspace, use the `//go:debug` directives or `GODEBUG` it prints, since go.work has no `godebug` lines. Pins are meant to be temporary: say that they're removed in later Go releases. 3. Pass on the tool's notes, such as the Go 1.22 loop variable change, or that a library's go line doesn't set GODEBUG for the programs that import it. ## Don't -- Don't edit go.mod unless the user asks. +- Don't edit go.mod or go.work unless the user asks. - Don't say a bump is safe because nothing is listed. The tool only covers GODEBUG defaults. Release notes list other changes. - Don't run the user's tests or binaries to check behavior unless they ask. diff --git a/tests/integration_test.go b/tests/integration_test.go index 2a894f9..706e59f 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -18,6 +18,7 @@ import ( type result struct { Module string + Workspace bool From, To string Binaries []string TestsOnly bool @@ -146,6 +147,52 @@ func TestShop(t *testing.T) { } } +func TestWorkspace(t *testing.T) { + needGo(t, 24) + before, _ := os.ReadFile("../examples/workspace/go.work") + r, code, stderr := diff(t, "--work", "--to", "1.24", "--fail", "../examples/workspace") + if code != 1 { + t.Fatalf("--fail should exit 1 when changes reach a binary, got %d: %s", code, stderr) + } + if !r.Workspace || r.Module != "workspace" || r.From != "1.21" || r.To != "1.24" { + t.Fatalf("header = %+v", r) + } + wantBins := []string{"example.com/workspace/app", "example.com/workspace/worker"} + if !reflect.DeepEqual(r.Binaries, wantBins) { + t.Fatalf("binaries = %v, want %v", r.Binaries, wantBins) + } + idx := byName(r) + app := []string{"example.com/workspace/app"} + both := wantBins + for name, want := range map[string][]string{ + "httpmuxgo121": app, + "randseednop": both, + } { + i, ok := idx[name] + if !ok { + t.Errorf("%s missing", name) + continue + } + if !reflect.DeepEqual(r.Changes[i].ReachedBy, want) { + t.Errorf("%s reached by %v, want %v", name, r.Changes[i].ReachedBy, want) + } + } + if !strings.Contains(strings.Join(r.Notes, " "), "go line in go.work") { + t.Errorf("notes = %v", r.Notes) + } + after, _ := os.ReadFile("../examples/workspace/go.work") + if !bytes.Equal(before, after) { + t.Error("godebugdiff changed examples/workspace/go.work") + } + out, err := exec.Command(bin, "--work", "--to", "1.24", "../examples/workspace").Output() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "go 1.21 -> 1.24 in workspace") || !strings.Contains(string(out), "//go:debug") { + t.Errorf("output:\n%s", out) + } +} + // buildDefault builds pkg with a go.mod copy at goVersion and returns the // DefaultGODEBUG the go command recorded in the binary's build info. func buildDefault(t *testing.T, dir, pkg, goVersion string) map[string]string { @@ -301,6 +348,9 @@ func TestErrors(t *testing.T) { if _, code, stderr := diff(t, "../examples"); code != 2 || !strings.Contains(stderr, "no go.mod") { t.Errorf("no go.mod: %d %s", code, stderr) } + if _, code, stderr := diff(t, "--work", "../examples"); code != 2 || !strings.Contains(stderr, "no go.work") { + t.Errorf("no go.work: %d %s", code, stderr) + } // A godebug line for a removed setting is an error from the go command. dir := t.TempDir() os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/old\n\ngo 1.21\n\ngodebug x509sha1=1\n"), 0o644)