Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion rewrite-go/cmd/rpc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,9 @@ func (s *server) handleParse(params json.RawMessage) (any, *rpcError) {
continue
}
if mrr, err := goparser.ParseGoMod(r.sourcePath, r.source); err == nil && mrr != nil && mrr.ModulePath != "" {
// No go.sum on this path (sources arrive as strings), so the require
// block is the only build list available.
mrr.ResolvedDependencies, mrr.ResolutionSource = goparser.DeriveBuildList(mrr.GoVersion, mrr.Requires, mrr.Replaces, nil)
gm.Markers.Entries = append(gm.Markers.Entries, *mrr)
}
goModByIdx[r.idx] = gm
Expand Down Expand Up @@ -2431,10 +2434,24 @@ func (s *server) handleParseProject(params json.RawMessage) (any, *rpcError) {
// the go.sum-only result (never fail the parse).
moduleDir := filepath.Dir(modPath)
if resolved, pkgs, rerr := goparser.ResolveModuleGraph(moduleDir); rerr != nil {
s.logger.Printf("ParseProject: module resolution failed for %s (go.sum-only): %v", moduleDir, rerr)
s.logger.Printf("ParseProject: module resolution failed for %s, falling back to vendor/go.mod: %v", moduleDir, rerr)
// vendor/modules.txt is authoritative for a vendored build and is the only
// offline source of the package->module map, so it outranks go.mod.
if vendored, verr := os.ReadFile(filepath.Join(moduleDir, "vendor", "modules.txt")); verr == nil {
vendorMods, vendorPkgs := goparser.ParseVendorModules(string(vendored))
mrr.ResolvedDependencies = goparser.MergeResolvedDependencies(mrr.ResolvedDependencies, vendorMods)
mrr.PackageModules = vendorPkgs
mrr.ResolutionSource = golang.ResolutionVendor
} else {
mrr.ResolvedDependencies, mrr.ResolutionSource = goparser.DeriveBuildList(mrr.GoVersion, mrr.Requires, mrr.Replaces, mrr.ResolvedDependencies)
}
// Each build-list member's own go.mod is already in the module cache when
// the repo has been built here, and its requires are that module's edges.
mrr.ResolvedDependencies = goparser.AttachCachedEdges(goparser.GoModCacheDir(), mrr.ResolvedDependencies)
} else {
mrr.ResolvedDependencies = goparser.MergeResolvedDependencies(mrr.ResolvedDependencies, resolved)
mrr.PackageModules = pkgs
mrr.ResolutionSource = golang.ResolutionToolchain
}
mods[filepath.Dir(modPath)] = &modCtx{
dir: filepath.Dir(modPath),
Expand Down
87 changes: 87 additions & 0 deletions rewrite-go/pkg/parser/gomod_buildlist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2026 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package parser

import (
"golang.org/x/mod/semver"

"github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang"
)

// pruningMinGoVersion is the `go` directive at which module graph pruning was
// introduced: from here on `go mod tidy` records an indirect require for every
// module providing a transitively imported package, which is what makes the
// expanded require block a build list.
const pruningMinGoVersion = "v1.17"

// DeriveBuildList produces the resolved build list for a main module from its
// require block plus go.sum's hash rows, without the Go toolchain. Pruning is
// what makes it sound (see pruningMinGoVersion); before that the require block
// holds only the direct roots and the go.sum rows are returned as-is.
func DeriveBuildList(goVersion string, requires []golang.GoRequire, replaces []golang.GoReplace, fromSum []golang.GoResolvedDependency) ([]golang.GoResolvedDependency, golang.GoResolutionSource) {
if !supportsPruning(goVersion) {
out := make([]golang.GoResolvedDependency, 0, len(fromSum))
for _, d := range fromSum {
d.Selected = false
out = append(out, d)
}
return out, golang.ResolutionGoSumOnly
}

buildList := make([]golang.GoResolvedDependency, 0, len(requires))
for _, r := range requires {
mod := golang.GoResolvedDependency{
ModulePath: r.ModulePath,
Version: r.Version,
Indirect: r.Indirect,
}
applyReplace(&mod, replaces)
buildList = append(buildList, mod)
}
return MergeResolvedDependencies(fromSum, buildList), golang.ResolutionGoMod
}

// applyReplace records the `replace` target for mod, matching how `go list -m`
// reports one. A replace with no old version binds every version of the path;
// with one it binds only that version. go.sum records the replacement's hashes
// under the replacement's own coordinate, so a replaced module has none here.
func applyReplace(mod *golang.GoResolvedDependency, replaces []golang.GoReplace) {
for _, r := range replaces {
if r.OldPath != mod.ModulePath {
continue
}
if r.OldVersion != "" && r.OldVersion != mod.Version {
continue
}
mod.ReplacePath = r.NewPath
mod.ReplaceVersion = r.NewVersion
return
}
}

// supportsPruning reports whether a `go` directive is at or past
// pruningMinGoVersion. An unparseable or absent directive predates it.
func supportsPruning(goVersion string) bool {
if goVersion == "" {
return false
}
v := "v" + goVersion
if !semver.IsValid(v) {
return false
}
return semver.Compare(semver.MajorMinor(v), pruningMinGoVersion) >= 0
}
161 changes: 161 additions & 0 deletions rewrite-go/pkg/parser/gomod_buildlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* Copyright 2026 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package parser

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang"
)

func sumRow(path, version, moduleHash string) golang.GoResolvedDependency {
return golang.GoResolvedDependency{ModulePath: path, Version: version, ModuleHash: moduleHash}
}

func TestDeriveBuildListFromPrunedRequires(t *testing.T) {
requires := []golang.GoRequire{
{ModulePath: "github.com/google/uuid", Version: "v1.6.0"},
{ModulePath: "golang.org/x/mod", Version: "v0.35.0", Indirect: true},
}

list, source := DeriveBuildList("1.25.0", requires, nil, nil)

assert.Equal(t, golang.ResolutionGoMod, source)
require.Len(t, list, 2)
assert.Equal(t, "github.com/google/uuid", list[0].ModulePath)
assert.Equal(t, "v1.6.0", list[0].Version)
assert.True(t, list[0].Selected)
assert.False(t, list[0].Indirect)
assert.True(t, list[1].Selected)
assert.True(t, list[1].Indirect, "// indirect requires stay flagged indirect in the build list")
}

func TestDeriveBuildListPrePruningFallsBackToGoSumOnly(t *testing.T) {
requires := []golang.GoRequire{{ModulePath: "github.com/google/uuid", Version: "v1.6.0"}}
sum := []golang.GoResolvedDependency{sumRow("github.com/google/uuid", "v1.6.0", "h1:aaa")}

list, source := DeriveBuildList("1.16", requires, nil, sum)

assert.Equal(t, golang.ResolutionGoSumOnly, source)
require.Len(t, list, 1)
assert.False(t, list[0].Selected, "nothing is selected when there is no build list")
}

func TestDeriveBuildListMissingGoDirectiveFallsBackToGoSumOnly(t *testing.T) {
requires := []golang.GoRequire{{ModulePath: "github.com/google/uuid", Version: "v1.6.0"}}

_, source := DeriveBuildList("", requires, nil, nil)

assert.Equal(t, golang.ResolutionGoSumOnly, source)
}

func TestDeriveBuildListJoinsGoSumHashes(t *testing.T) {
requires := []golang.GoRequire{{ModulePath: "github.com/google/uuid", Version: "v1.6.0"}}
sum := []golang.GoResolvedDependency{
{ModulePath: "github.com/google/uuid", Version: "v1.6.0", ModuleHash: "h1:zip", GoModHash: "h1:mod"},
}

list, _ := DeriveBuildList("1.21", requires, nil, sum)

require.Len(t, list, 1)
assert.Equal(t, "h1:zip", list[0].ModuleHash)
assert.Equal(t, "h1:mod", list[0].GoModHash)
assert.True(t, list[0].Selected)
}

func TestDeriveBuildListMarksRejectedGoSumVersionsUnselected(t *testing.T) {
requires := []golang.GoRequire{{ModulePath: "golang.org/x/mod", Version: "v0.35.0"}}
sum := []golang.GoResolvedDependency{
sumRow("golang.org/x/mod", "v0.35.0", "h1:new"),
sumRow("golang.org/x/mod", "v0.27.0", "h1:old"),
}

list, source := DeriveBuildList("1.21", requires, nil, sum)

assert.Equal(t, golang.ResolutionGoMod, source)
require.Len(t, list, 2)

selected := map[string]bool{}
for _, d := range list {
selected[d.Version] = d.Selected
}
assert.True(t, selected["v0.35.0"])
assert.False(t, selected["v0.27.0"], "a version MVS rejected is not in the build")
}

func TestDeriveBuildListEmptyRequires(t *testing.T) {
list, source := DeriveBuildList("1.21", nil, nil, nil)

assert.Equal(t, golang.ResolutionGoMod, source)
assert.NotNil(t, list, "callers assign this straight onto the marker; nil serializes as a null list")
assert.Empty(t, list)
}

func TestSupportsPruning(t *testing.T) {
for _, tc := range []struct {
goVersion string
want bool
}{
{"1.17", true},
{"1.16", false},
{"1.9", false},
{"1.21.5", true},
{"1.25.0", true},
{"", false},
{"garbage", false},
} {
assert.Equalf(t, tc.want, supportsPruning(tc.goVersion), "go %q", tc.goVersion)
}
}

func TestDeriveBuildListAppliesVersionedReplace(t *testing.T) {
requires := []golang.GoRequire{{ModulePath: "golang.org/x/net", Version: "v1.2.3"}}
replaces := []golang.GoReplace{
{OldPath: "golang.org/x/net", OldVersion: "v1.2.3", NewPath: "example.com/fork", NewVersion: "v2.0.0"},
}

list, _ := DeriveBuildList("1.21", requires, replaces, nil)

require.Len(t, list, 1)
assert.Equal(t, "example.com/fork", list[0].ReplacePath)
assert.Equal(t, "v2.0.0", list[0].ReplaceVersion)
}

func TestDeriveBuildListAppliesWildcardReplace(t *testing.T) {
requires := []golang.GoRequire{{ModulePath: "golang.org/x/net", Version: "v1.2.3"}}
replaces := []golang.GoReplace{{OldPath: "golang.org/x/net", NewPath: "./forks/net"}}

list, _ := DeriveBuildList("1.21", requires, replaces, nil)

require.Len(t, list, 1)
assert.Equal(t, "./forks/net", list[0].ReplacePath)
assert.Empty(t, list[0].ReplaceVersion, "a local path replacement carries no version")
}

func TestDeriveBuildListIgnoresReplaceForOtherVersion(t *testing.T) {
requires := []golang.GoRequire{{ModulePath: "golang.org/x/net", Version: "v1.2.3"}}
replaces := []golang.GoReplace{
{OldPath: "golang.org/x/net", OldVersion: "v0.9.0", NewPath: "example.com/fork", NewVersion: "v2.0.0"},
}

list, _ := DeriveBuildList("1.21", requires, replaces, nil)

assert.Empty(t, list[0].ReplacePath, "a versioned replace binds only that version")
}
99 changes: 99 additions & 0 deletions rewrite-go/pkg/parser/gomod_cachegraph.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright 2026 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package parser

import (
"os"
"path/filepath"
"strings"

"golang.org/x/mod/modfile"
"golang.org/x/mod/module"

"github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang"
)

// AttachCachedEdges fills in each selected build-list member's Deps from the copy
// of its go.mod in the module cache, whose requires are that module's edges — the
// same set `go mod graph` prints. The build list supplies every version, so this
// reads the cache and nothing else.
//
// Deps stays nil for a module the cache does not hold, which is distinct from an
// empty slice: a partially warm cache must not read as a module with no
// dependencies.
func AttachCachedEdges(cacheDir string, buildList []golang.GoResolvedDependency) []golang.GoResolvedDependency {
out := make([]golang.GoResolvedDependency, len(buildList))
copy(out, buildList)
if cacheDir == "" {
return out
}
for i, mod := range out {
if !mod.Selected {
continue
}
if deps, ok := cachedModuleEdges(cacheDir, mod.ModulePath, mod.Version); ok {
out[i].Deps = deps
}
}
return out
}

// cachedModuleEdges reads $GOMODCACHE/cache/download/<escaped>/@v/<version>.mod.
// A module whose own go.mod is unreadable or unparseable contributes no edges
// rather than failing the parse.
func cachedModuleEdges(cacheDir, modulePath, version string) ([]golang.GoModuleRef, bool) {
// The cache lowercases each uppercase letter behind a `!` so its layout
// survives case-insensitive filesystems. Versions carry the same encoding.
escapedPath, err := module.EscapePath(modulePath)
if err != nil {
return nil, false
}
escapedVersion, err := module.EscapeVersion(version)
if err != nil {
return nil, false
}
path := filepath.Join(cacheDir, "cache", "download", escapedPath, "@v", escapedVersion+".mod")
content, err := os.ReadFile(path)
if err != nil {
return nil, false
}
f, err := modfile.Parse(path, content, nil)
if err != nil {
return nil, false
}
edges := make([]golang.GoModuleRef, 0, len(f.Require))
for _, r := range f.Require {
edges = append(edges, golang.GoModuleRef{ModulePath: r.Mod.Path, Version: r.Mod.Version})
}
return edges, true
}

func GoModCacheDir() string {
if dir := os.Getenv("GOMODCACHE"); dir != "" {
return dir
}
if gopath := os.Getenv("GOPATH"); gopath != "" {
// GOPATH is a list; the module cache lives under its first entry.
first, _, _ := strings.Cut(gopath, string(os.PathListSeparator))
return filepath.Join(first, "pkg", "mod")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, "go", "pkg", "mod")
}
Loading
Loading