diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index 13f816717e..5e1a80ce61 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -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 @@ -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), diff --git a/rewrite-go/pkg/parser/gomod_buildlist.go b/rewrite-go/pkg/parser/gomod_buildlist.go new file mode 100644 index 0000000000..82841cde20 --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_buildlist.go @@ -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 +} diff --git a/rewrite-go/pkg/parser/gomod_buildlist_test.go b/rewrite-go/pkg/parser/gomod_buildlist_test.go new file mode 100644 index 0000000000..044a3c7d60 --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_buildlist_test.go @@ -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") +} diff --git a/rewrite-go/pkg/parser/gomod_cachegraph.go b/rewrite-go/pkg/parser/gomod_cachegraph.go new file mode 100644 index 0000000000..30daf8524f --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_cachegraph.go @@ -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//@v/.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") +} diff --git a/rewrite-go/pkg/parser/gomod_cachegraph_test.go b/rewrite-go/pkg/parser/gomod_cachegraph_test.go new file mode 100644 index 0000000000..4a7096f655 --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_cachegraph_test.go @@ -0,0 +1,153 @@ +/* + * 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" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/mod/module" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" +) + +// writeCachedGoMod lays out $GOMODCACHE/cache/download//@v/.mod, +// the path the Go toolchain populates when it downloads a module. +func writeCachedGoMod(t *testing.T, cache, modulePath, version, content string) { + t.Helper() + escaped, err := module.EscapePath(modulePath) + require.NoError(t, err) + dir := filepath.Join(cache, "cache", "download", escaped, "@v") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, version+".mod"), []byte(content), 0o644)) +} + +func buildListOf(pairs ...string) []golang.GoResolvedDependency { + var out []golang.GoResolvedDependency + for i := 0; i < len(pairs); i += 2 { + out = append(out, golang.GoResolvedDependency{ModulePath: pairs[i], Version: pairs[i+1], Selected: true}) + } + return out +} + +func TestAttachCachedEdges(t *testing.T) { + cache := t.TempDir() + writeCachedGoMod(t, cache, "example.com/a", "v1.0.0", `module example.com/a +go 1.21 +require example.com/b v1.2.0 +`) + writeCachedGoMod(t, cache, "example.com/b", "v1.2.0", "module example.com/b\ngo 1.21\n") + + list := buildListOf("example.com/a", "v1.0.0", "example.com/b", "v1.2.0") + attached := AttachCachedEdges(cache, list) + + require.Len(t, attached, 2) + require.Len(t, attached[0].Deps, 1) + assert.Equal(t, "example.com/b", attached[0].Deps[0].ModulePath) + assert.Equal(t, "v1.2.0", attached[0].Deps[0].Version) + assert.Empty(t, attached[1].Deps) +} + +func TestAttachCachedEdgesEscapesUppercasePaths(t *testing.T) { + cache := t.TempDir() + writeCachedGoMod(t, cache, "github.com/Azure/go-autorest", "v1.0.0", `module github.com/Azure/go-autorest +require example.com/b v1.2.0 +`) + escaped, err := module.EscapePath("github.com/Azure/go-autorest") + require.NoError(t, err) + assert.Contains(t, escaped, "!azure") + + attached := AttachCachedEdges(cache, buildListOf("github.com/Azure/go-autorest", "v1.0.0")) + + require.Len(t, attached[0].Deps, 1) + assert.Equal(t, "example.com/b", attached[0].Deps[0].ModulePath) +} + +func TestAttachCachedEdgesLeavesUncachedModulesNil(t *testing.T) { + cache := t.TempDir() + writeCachedGoMod(t, cache, "example.com/a", "v1.0.0", "module example.com/a\nrequire example.com/b v1.2.0\n") + + attached := AttachCachedEdges(cache, buildListOf("example.com/a", "v1.0.0", "example.com/absent", "v9.9.9")) + + assert.NotNil(t, attached[0].Deps) + assert.Nil(t, attached[1].Deps, "absent from the cache is not the same as having no dependencies") +} + +func TestAttachCachedEdgesSkipsUnselectedRows(t *testing.T) { + cache := t.TempDir() + writeCachedGoMod(t, cache, "example.com/a", "v1.0.0", "module example.com/a\nrequire example.com/b v1.2.0\n") + + list := []golang.GoResolvedDependency{{ModulePath: "example.com/a", Version: "v1.0.0", Selected: false}} + attached := AttachCachedEdges(cache, list) + + assert.Nil(t, attached[0].Deps, "a version MVS rejected has no edges in this build") +} + +func TestAttachCachedEdgesTolerearesMalformedGoMod(t *testing.T) { + cache := t.TempDir() + writeCachedGoMod(t, cache, "example.com/a", "v1.0.0", "this is not a go.mod {{{") + + attached := AttachCachedEdges(cache, buildListOf("example.com/a", "v1.0.0")) + + assert.Nil(t, attached[0].Deps) +} + +func TestAttachCachedEdgesNoCacheDir(t *testing.T) { + attached := AttachCachedEdges("", buildListOf("example.com/a", "v1.0.0")) + + assert.Nil(t, attached[0].Deps) +} + +func TestCachedEdgesEnrichVendoredBuildList(t *testing.T) { + cache := t.TempDir() + writeCachedGoMod(t, cache, "golang.org/x/mod", "v0.35.0", `module golang.org/x/mod +require golang.org/x/tools v0.43.0 +`) + vendored, _ := ParseVendorModules("# golang.org/x/mod v0.35.0\n## explicit\ngolang.org/x/mod/modfile\n") + + attached := AttachCachedEdges(cache, vendored) + + require.Len(t, attached[0].Deps, 1) + assert.Equal(t, "golang.org/x/tools", attached[0].Deps[0].ModulePath) +} + +func TestAttachCachedEdgesEscapesUppercaseVersions(t *testing.T) { + cache := t.TempDir() + escaped, err := module.EscapeVersion("v1.0.0-RC1") + require.NoError(t, err) + assert.Contains(t, escaped, "!r!c1", "the cache escapes the version, not just the path") + dir := filepath.Join(cache, "cache", "download", "example.com/a", "@v") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, escaped+".mod"), + []byte("module example.com/a\nrequire example.com/b v1.2.0\n"), 0o644)) + + attached := AttachCachedEdges(cache, buildListOf("example.com/a", "v1.0.0-RC1")) + + require.Len(t, attached[0].Deps, 1) + assert.Equal(t, "example.com/b", attached[0].Deps[0].ModulePath) +} + +func TestGoModCacheDirUsesFirstGopathEntry(t *testing.T) { + t.Setenv("GOMODCACHE", "") + t.Setenv("GOPATH", strings.Join([]string{"/first", "/second"}, string(os.PathListSeparator))) + + assert.Equal(t, filepath.Join("/first", "pkg", "mod"), GoModCacheDir()) +} diff --git a/rewrite-go/pkg/parser/gomod_resolve.go b/rewrite-go/pkg/parser/gomod_resolve.go index faee6a3842..e5713ed214 100644 --- a/rewrite-go/pkg/parser/gomod_resolve.go +++ b/rewrite-go/pkg/parser/gomod_resolve.go @@ -210,8 +210,8 @@ func runGo(dir string, args ...string) ([]byte, error) { // go.sum-derived hash rows, keyed by module@version. Build-list nodes are // authoritative (they carry the selected version, indirect/main flags, replace // info and graph edges) and inherit the go.sum content hashes for their version. -// go.sum rows whose version is not in the selected build list (stale/extra -// hashes) are preserved. Pure function — unit-testable without the toolchain. +// go.sum rows outside the build list are preserved, unselected. Pure function — +// unit-testable without the toolchain. func MergeResolvedDependencies(fromSum, fromList []golang.GoResolvedDependency) []golang.GoResolvedDependency { key := func(d golang.GoResolvedDependency) string { return d.ModulePath + "@" + d.Version } sumByKey := make(map[string]golang.GoResolvedDependency, len(fromSum)) @@ -226,11 +226,13 @@ func MergeResolvedDependencies(fromSum, fromList []golang.GoResolvedDependency) m.ModuleHash = s.ModuleHash m.GoModHash = s.GoModHash } + m.Selected = true out = append(out, m) seen[k] = true } for _, s := range fromSum { if !seen[key(s)] { + s.Selected = false out = append(out, s) } } diff --git a/rewrite-go/pkg/parser/gomod_vendor.go b/rewrite-go/pkg/parser/gomod_vendor.go new file mode 100644 index 0000000000..27b56fd307 --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_vendor.go @@ -0,0 +1,104 @@ +/* + * 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 ( + "strings" + + "github.com/openrewrite/rewrite/rewrite-go/pkg/tree/golang" +) + +// ParseVendorModules reads a vendor/modules.txt into the vendored build list and +// the package-to-module map. Its grammar is a `# module version [=> replacement]` +// line, an optional `## marker; marker` line, then one line per vendored package: +// +// # golang.org/x/mod v0.35.0 +// ## explicit; go 1.23 +// golang.org/x/mod/modfile +// +// A module without the `explicit` marker is not required by the main module +// directly, which is the same distinction `go list -m` reports as indirect. +func ParseVendorModules(content string) ([]golang.GoResolvedDependency, []golang.GoPackageModule) { + mods := []golang.GoResolvedDependency{} + pkgs := []golang.GoPackageModule{} + + current := -1 + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + switch { + case line == "": + continue + + case strings.HasPrefix(line, "##"): + if current < 0 { + continue + } + for _, marker := range strings.Split(strings.TrimPrefix(line, "##"), ";") { + marker = strings.TrimSpace(marker) + if marker == "explicit" { + mods[current].Indirect = false + } else if goVersion := strings.TrimPrefix(marker, "go "); goVersion != marker { + mods[current].ModuleGoVersion = strings.TrimSpace(goVersion) + } + } + + case strings.HasPrefix(line, "#"): + mod, ok := parseVendorModuleLine(strings.TrimSpace(strings.TrimPrefix(line, "#"))) + if !ok { + current = -1 + continue + } + mods = append(mods, mod) + current = len(mods) - 1 + + default: + if current < 0 { + continue + } + pkgs = append(pkgs, golang.GoPackageModule{ + ImportPath: line, + ModulePath: mods[current].ModulePath, + Version: mods[current].Version, + }) + } + } + return mods, pkgs +} + +// parseVendorModuleLine reads `path version [=> path [version]]`. Everything in +// modules.txt is vendored into the build, hence Selected; Indirect starts true +// because only an `explicit` marker line establishes a direct requirement. +func parseVendorModuleLine(line string) (golang.GoResolvedDependency, bool) { + spec, replacement, _ := strings.Cut(line, "=>") + fields := strings.Fields(spec) + if len(fields) < 2 { + return golang.GoResolvedDependency{}, false + } + mod := golang.GoResolvedDependency{ + ModulePath: fields[0], + Version: fields[1], + Indirect: true, + Selected: true, + } + if replaced := strings.Fields(replacement); len(replaced) > 0 { + mod.ReplacePath = replaced[0] + if len(replaced) > 1 { + mod.ReplaceVersion = replaced[1] + } + } + return mod, true +} diff --git a/rewrite-go/pkg/parser/gomod_vendor_test.go b/rewrite-go/pkg/parser/gomod_vendor_test.go new file mode 100644 index 0000000000..bd017692a9 --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_vendor_test.go @@ -0,0 +1,117 @@ +/* + * 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" +) + +const modulesTxt = `# github.com/google/uuid v1.6.0 +## explicit; go 1.16 +github.com/google/uuid +# golang.org/x/mod v0.35.0 +## explicit; go 1.23 +golang.org/x/mod/modfile +golang.org/x/mod/semver +# golang.org/x/tools v0.43.0 +## go 1.23 +golang.org/x/tools/go/ast/astutil +# rsc.io/quote v1.5.2 => rsc.io/quote/v3 v3.1.0 +## explicit +rsc.io/quote +` + +func TestParseVendorModulesBuildList(t *testing.T) { + mods, _ := ParseVendorModules(modulesTxt) + + require.Len(t, mods, 4) + assert.Equal(t, "github.com/google/uuid", mods[0].ModulePath) + assert.Equal(t, "v1.6.0", mods[0].Version) + for _, m := range mods { + assert.Truef(t, m.Selected, "%s is in the vendored build", m.ModulePath) + } +} + +func TestParseVendorModulesMarksNonExplicitIndirect(t *testing.T) { + mods, _ := ParseVendorModules(modulesTxt) + + byPath := map[string]bool{} + for _, m := range mods { + byPath[m.ModulePath] = m.Indirect + } + assert.False(t, byPath["github.com/google/uuid"]) + assert.True(t, byPath["golang.org/x/tools"], "no ## explicit marker means it is required transitively") +} + +func TestParseVendorModulesCapturesReplacement(t *testing.T) { + mods, _ := ParseVendorModules(modulesTxt) + + var quote *struct{ path, version string } + for _, m := range mods { + if m.ModulePath == "rsc.io/quote" { + quote = &struct{ path, version string }{m.ReplacePath, m.ReplaceVersion} + } + } + require.NotNil(t, quote) + assert.Equal(t, "rsc.io/quote/v3", quote.path) + assert.Equal(t, "v3.1.0", quote.version) +} + +func TestParseVendorModulesCapturesModuleGoVersion(t *testing.T) { + mods, _ := ParseVendorModules(modulesTxt) + + for _, m := range mods { + if m.ModulePath == "golang.org/x/mod" { + assert.Equal(t, "1.23", m.ModuleGoVersion) + return + } + } + t.Fatal("golang.org/x/mod missing from build list") +} + +func TestParseVendorModulesPackageMap(t *testing.T) { + _, pkgs := ParseVendorModules(modulesTxt) + + byImport := map[string]string{} + for _, p := range pkgs { + byImport[p.ImportPath] = p.ModulePath + assert.Falsef(t, p.Standard, "%s is vendored, not stdlib", p.ImportPath) + } + assert.Equal(t, "golang.org/x/mod", byImport["golang.org/x/mod/modfile"]) + assert.Equal(t, "golang.org/x/mod", byImport["golang.org/x/mod/semver"]) + assert.Equal(t, "golang.org/x/tools", byImport["golang.org/x/tools/go/ast/astutil"]) + assert.Len(t, pkgs, 5) +} + +func TestParseVendorModulesEmpty(t *testing.T) { + mods, pkgs := ParseVendorModules("") + + assert.NotNil(t, mods) + assert.Empty(t, mods) + assert.NotNil(t, pkgs) + assert.Empty(t, pkgs) +} + +func TestParseVendorModulesIgnoresMalformedLines(t *testing.T) { + mods, pkgs := ParseVendorModules("# nonsense\n## explicit\nsome/pkg\n#\n\n") + + assert.Empty(t, mods, "a `# module` line without a version names no module version") + assert.Empty(t, pkgs, "a package line outside any module belongs to nothing") +} diff --git a/rewrite-go/pkg/rpc/go_resolution_result_codec.go b/rewrite-go/pkg/rpc/go_resolution_result_codec.go index e37b277543..0bad25453c 100644 --- a/rewrite-go/pkg/rpc/go_resolution_result_codec.go +++ b/rewrite-go/pkg/rpc/go_resolution_result_codec.go @@ -118,6 +118,7 @@ func sendGoResolutionResult(m golang.GoResolutionResult, q *SendQueue) { q.GetAndSend(d, func(y any) any { return emptyAsNil(y.(golang.GoResolvedDependency).ReplacePath) }, nil) q.GetAndSend(d, func(y any) any { return emptyAsNil(y.(golang.GoResolvedDependency).ReplaceVersion) }, nil) q.GetAndSend(d, func(y any) any { return emptyAsNil(y.(golang.GoResolvedDependency).ModuleGoVersion) }, nil) + q.GetAndSend(d, func(y any) any { return y.(golang.GoResolvedDependency).Selected }, nil) q.GetAndSendListAsRef(d, func(y any) []any { return moduleRefSlice(y.(golang.GoResolvedDependency).Deps) }, func(y any) any { @@ -141,6 +142,8 @@ func sendGoResolutionResult(m golang.GoResolutionResult, q *SendQueue) { q.GetAndSend(p, func(y any) any { return emptyAsNil(y.(golang.GoPackageModule).Version) }, nil) q.GetAndSend(p, func(y any) any { return y.(golang.GoPackageModule).Standard }, nil) }) + + q.GetAndSend(m, func(x any) any { return string(x.(golang.GoResolutionResult).ResolutionSource) }, nil) } // receiveGoResolutionResult mirrors Java's @@ -163,6 +166,8 @@ func receiveGoResolutionResult(before golang.GoResolutionResult, q *ReceiveQueue before.Retracts = recvRetracts(q, before.Retracts) before.ResolvedDependencies = recvResolvedDeps(q, before.ResolvedDependencies) before.PackageModules = recvPackageModules(q, before.PackageModules) + before.ResolutionSource = golang.GoResolutionSource( + receiveScalar[string](q, string(before.ResolutionSource))) return before } @@ -254,6 +259,7 @@ func recvResolvedDeps(q *ReceiveQueue, before []golang.GoResolvedDependency) []g d.ReplacePath = receiveNullableString(q, d.ReplacePath) d.ReplaceVersion = receiveNullableString(q, d.ReplaceVersion) d.ModuleGoVersion = receiveNullableString(q, d.ModuleGoVersion) + d.Selected = receiveScalar[bool](q, d.Selected) d.Deps = recvModuleRefs(q, d.Deps) return d }) diff --git a/rewrite-go/pkg/rpc/marker_codec_test.go b/rewrite-go/pkg/rpc/marker_codec_test.go index 5f4eb284e8..b567aa4bb7 100644 --- a/rewrite-go/pkg/rpc/marker_codec_test.go +++ b/rewrite-go/pkg/rpc/marker_codec_test.go @@ -99,7 +99,7 @@ func TestGoResolutionResultMarkerRoundTrip(t *testing.T) { { ModulePath: "github.com/google/uuid", Version: "v1.6.0", ModuleHash: "h1:abc=", GoModHash: "h1:def=", - Main: true, ModuleGoVersion: "1.22", + Main: true, ModuleGoVersion: "1.22", Selected: true, Deps: []golang.GoModuleRef{ {ModulePath: "golang.org/x/mod", Version: "v0.35.0"}, }, @@ -114,6 +114,7 @@ func TestGoResolutionResultMarkerRoundTrip(t *testing.T) { {ImportPath: "fmt", Standard: true}, {ImportPath: "github.com/google/uuid", ModulePath: "github.com/google/uuid", Version: "v1.6.0"}, }, + ResolutionSource: golang.ResolutionToolchain, } before := java.Markers{ID: uuid.New(), Entries: []java.Marker{mrr}} @@ -137,6 +138,8 @@ func TestGoResolutionResultEmptyListsRoundTrip(t *testing.T) { Replaces: []golang.GoReplace{}, Excludes: []golang.GoExclude{}, Retracts: []golang.GoRetract{}, + + ResolutionSource: golang.ResolutionGoSumOnly, } before := java.Markers{ID: uuid.New(), Entries: []java.Marker{mrr}} diff --git a/rewrite-go/pkg/tree/golang/go_resolution_result.go b/rewrite-go/pkg/tree/golang/go_resolution_result.go index 8285dd752a..6351fb5312 100644 --- a/rewrite-go/pkg/tree/golang/go_resolution_result.go +++ b/rewrite-go/pkg/tree/golang/go_resolution_result.go @@ -39,6 +39,51 @@ type GoResolutionResult struct { // coordinate, so this mapping requires toolchain resolution. Empty unless // the parse-time resolution gate is on. PackageModules []GoPackageModule + // ResolutionSource records how ResolvedDependencies was derived. Prefer + // HasBuildList/HasGraph over comparing constants. + ResolutionSource GoResolutionSource +} + +// GoResolutionSource is how a GoResolutionResult's build list was derived. +type GoResolutionSource string + +const ( + // ResolutionToolchain: build list, graph edges and the package->module map, + // from `go list -m`, `go mod graph` and `go list -deps`. + ResolutionToolchain GoResolutionSource = "TOOLCHAIN" + // ResolutionGoMod: build list from the main module's require block under + // Go 1.17+ pruning. No package->module map. + ResolutionGoMod GoResolutionSource = "GO_MOD" + // ResolutionVendor: build list and package->module map from vendor/modules.txt, + // which is authoritative for a vendored build. + ResolutionVendor GoResolutionSource = "VENDOR" + // ResolutionGoSumOnly: no build list — ResolvedDependencies holds go.sum + // hash rows only. + ResolutionGoSumOnly GoResolutionSource = "GO_SUM_ONLY" +) + +// HasBuildList reports whether ResolvedDependencies' selected rows are the +// modules that actually build, rather than a go.sum hash inventory. +func (m GoResolutionResult) HasBuildList() bool { + switch m.ResolutionSource { + case ResolutionToolchain, ResolutionVendor, ResolutionGoMod: + return true + default: + return false + } +} + +// HasGraph reports whether any build-list member carries Deps, i.e. whether +// transitive questions can be answered. Edges are an enrichment over whatever +// build list was derived, so this is a property of the data rather than of +// ResolutionSource. +func (m GoResolutionResult) HasGraph() bool { + for _, d := range m.ResolvedDependencies { + if d.Deps != nil { + return true + } + } + return false } func (m GoResolutionResult) ID() uuid.UUID { return m.Ident } @@ -112,6 +157,10 @@ type GoResolvedDependency struct { ReplacePath string // toolchain-applied replace target, empty if none ReplaceVersion string ModuleGoVersion string // this module's own `go` directive, from `go list -m` + // Selected marks a member of the resolved build list. go.sum also records + // versions MVS rejected; those are carried for their hashes with Selected + // false and are not part of the build. + Selected bool // Deps are the direct module dependencies of this node (from `go mod graph`), // referenced by module@version. Resolve against ResolvedDependencies. Nil when // the graph is unavailable. Edges (not nested nodes) keep this cycle-safe and @@ -153,5 +202,6 @@ func NewGoResolutionResult(modulePath, goVersion, toolchain, path string) GoReso Retracts: []GoRetract{}, ResolvedDependencies: []GoResolvedDependency{}, PackageModules: []GoPackageModule{}, + ResolutionSource: ResolutionGoSumOnly, } } diff --git a/rewrite-go/src/integTest/java/org/openrewrite/golang/rpc/MarkerRoundTripTest.java b/rewrite-go/src/integTest/java/org/openrewrite/golang/rpc/MarkerRoundTripTest.java index 91421fb478..3c4acbdabe 100644 --- a/rewrite-go/src/integTest/java/org/openrewrite/golang/rpc/MarkerRoundTripTest.java +++ b/rewrite-go/src/integTest/java/org/openrewrite/golang/rpc/MarkerRoundTripTest.java @@ -124,7 +124,7 @@ void goResolutionResultMarkerRoundTripsViaPrint() { "github.com/google/uuid", "v1.6.0", "h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=", "h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=", - false, true, null, null, "1.22", + false, true, null, null, "1.22", true, singletonList( new GoResolutionResult.ModuleRef("golang.org/x/mod", "v0.35.0"))) ), @@ -132,7 +132,8 @@ void goResolutionResultMarkerRoundTripsViaPrint() { new GoResolutionResult.PackageModule("fmt", null, null, true), new GoResolutionResult.PackageModule("github.com/google/uuid", "github.com/google/uuid", "v1.6.0", false) - ) + ), + GoResolutionResult.ResolutionSource.TOOLCHAIN ); cu = cu.withMarkers(cu.getMarkers().addIfAbsent(marker)); @@ -161,7 +162,8 @@ void emptyGoResolutionResultRoundTripsViaPrint() { emptyList(), emptyList(), emptyList(), - emptyList() + emptyList(), + GoResolutionResult.ResolutionSource.GO_SUM_ONLY ); cu = cu.withMarkers(cu.getMarkers().addIfAbsent(marker)); @@ -191,7 +193,8 @@ void bothMarkersTogetherRoundTripViaPrint() { emptyList(), emptyList(), emptyList(), - emptyList() + emptyList(), + GoResolutionResult.ResolutionSource.GO_MOD )); cu = cu.withMarkers(markers); @@ -223,12 +226,13 @@ void roundTripPreservesGoResolutionResultFieldsViaVisit() { singletonList( new GoResolutionResult.ResolvedDependency( "github.com/google/uuid", "v1.6.0", "h1:abc=", "h1:def=", - false, true, null, null, "1.22", + false, true, null, null, "1.22", true, singletonList( new GoResolutionResult.ModuleRef("golang.org/x/mod", "v0.35.0")))), singletonList( new GoResolutionResult.PackageModule("github.com/google/uuid", - "github.com/google/uuid", "v1.6.0", false))))); + "github.com/google/uuid", "v1.6.0", false)), + GoResolutionResult.ResolutionSource.TOOLCHAIN))); var recipe = rpc.prepareRecipe("org.openrewrite.golang.test.RenameXToFlag"); Tree result = recipe.getVisitor().visit(cu, new org.openrewrite.InMemoryExecutionContext()); @@ -251,12 +255,16 @@ void roundTripPreservesGoResolutionResultFieldsViaVisit() { GoResolutionResult.ResolvedDependency rd = mrr.getResolvedDependencies().get(0); assertThat(rd.isMain()).isTrue(); assertThat(rd.getModuleGoVersion()).isEqualTo("1.22"); + assertThat(rd.isSelected()).isTrue(); assertThat(rd.getDeps()).singleElement().satisfies(ref -> assertThat(ref.getModulePath()).isEqualTo("golang.org/x/mod")); assertThat(mrr.getPackageModules()).singleElement().satisfies(pm -> { assertThat(pm.getImportPath()).isEqualTo("github.com/google/uuid"); assertThat(pm.getModulePath()).isEqualTo("github.com/google/uuid"); }); + assertThat(mrr.getResolutionSource()).isEqualTo(GoResolutionResult.ResolutionSource.TOOLCHAIN); + assertThat(mrr.hasBuildList()).isTrue(); + assertThat(mrr.hasGraph()).isTrue(); } /** diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java b/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java index 5226ae8d09..ad4e6ec852 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java @@ -23,6 +23,7 @@ import org.openrewrite.golang.marker.GoResolutionResult; import org.openrewrite.golang.marker.GoResolutionResult.Exclude; import org.openrewrite.golang.marker.GoResolutionResult.Replace; +import org.openrewrite.golang.marker.GoResolutionResult.ResolutionSource; import org.openrewrite.golang.marker.GoResolutionResult.ResolvedDependency; import org.openrewrite.golang.marker.GoResolutionResult.Retract; import org.openrewrite.golang.marker.GoResolutionResult.Require; @@ -32,7 +33,11 @@ import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -111,16 +116,15 @@ public Stream parseInputs(Iterable sources, @Nullable Path re // The Go server attaches the GoResolutionResult marker during parsing. GoRewriteRpc rpc = GoRewriteRpc.getOrStart(); return rpc.parse(sources, relativeTo, this, GoMod.class.getName(), ctx) - .map(GoModParser::withSumHashes); + .map(sf -> withSumHashes(sf, relativeTo)); } /** * Enrich the {@link GoResolutionResult} marker with hashes from a sibling - * {@code go.sum}, if one is readable on disk next to the go.mod. The Go-side - * parser only sees go.mod content (sources travel as strings over RPC), so - * go.sum resolution stays here. + * {@code go.sum}. The Go-side parser only sees go.mod content (sources travel + * as strings over RPC), so go.sum resolution stays here. */ - private static SourceFile withSumHashes(SourceFile sf) { + private static SourceFile withSumHashes(SourceFile sf, @Nullable Path relativeTo) { if (!(sf instanceof GoMod)) { return sf; } @@ -128,16 +132,46 @@ private static SourceFile withSumHashes(SourceFile sf) { return gm.getMarkers().findFirst(GoResolutionResult.class) .filter(marker -> marker.getModulePath() != null && !marker.getModulePath().isEmpty()) .map(marker -> { - List resolved = parseSumSibling(gm.getSourcePath()); - if (resolved.isEmpty()) { + List fromSum = parseSumSibling(gm.getSourcePath(), relativeTo); + if (fromSum.isEmpty()) { return sf; } - return (SourceFile) gm.withMarkers( - gm.getMarkers().setByType(marker.withResolvedDependencies(resolved))); + return (SourceFile) gm.withMarkers(gm.getMarkers().setByType(marker.withResolvedDependencies( + mergeSumHashes(marker.getResolvedDependencies(), fromSum)))); }) .orElse(sf); } + /** + * Overlay go.sum hashes onto the build list, joined on {@code module@version}. + * Sum rows outside the build list are preserved, unselected. Mirrors the Go-side + * {@code MergeResolvedDependencies}. + */ + static List mergeSumHashes(@Nullable List buildList, + List fromSum) { + if (buildList == null || buildList.isEmpty()) { + return fromSum; + } + Map sumByKey = new LinkedHashMap<>(); + for (ResolvedDependency d : fromSum) { + sumByKey.put(d.getModulePath() + "@" + d.getVersion(), d); + } + List out = new ArrayList<>(buildList.size() + fromSum.size()); + Set seen = new HashSet<>(); + for (ResolvedDependency d : buildList) { + String key = d.getModulePath() + "@" + d.getVersion(); + ResolvedDependency sum = sumByKey.get(key); + out.add(sum == null ? d : d.withModuleHash(sum.getModuleHash()).withGoModHash(sum.getGoModHash())); + seen.add(key); + } + for (ResolvedDependency d : fromSum) { + if (!seen.contains(d.getModulePath() + "@" + d.getVersion())) { + out.add(d.withSelected(false)); + } + } + return out; + } + @Override public boolean accept(Path path) { String filename = path.getFileName().toString(); @@ -221,7 +255,7 @@ public Path sourcePathFromSourceText(Path prefix, String sourceCode) { return null; } - List resolved = parseSumSibling(doc.getSourcePath()); + List resolved = parseSumSibling(doc.getSourcePath(), null); return new GoResolutionResult( Tree.randomId(), @@ -234,7 +268,8 @@ public Path sourcePathFromSourceText(Path prefix, String sourceCode) { excludes, retracts, resolved, - new ArrayList<>() + new ArrayList<>(), + ResolutionSource.GO_SUM_ONLY ); } @@ -270,8 +305,17 @@ private static void parseBlockEntry(BlockState block, String rawLine, String lin } } - private static List parseSumSibling(Path goModPath) { - Path sumPath = goModPath.resolveSibling("go.sum"); + /** + * A source path is a repo-relative identifier, so {@code relativeTo} is what turns + * it back into a filesystem location; without one there is nothing to read. A relative + * root is itself relative to the working directory. + */ + static List parseSumSibling(Path goModPath, @Nullable Path relativeTo) { + Path onDisk = relativeTo == null ? goModPath : relativeTo.resolve(goModPath).toAbsolutePath(); + if (!onDisk.isAbsolute()) { + return new ArrayList<>(); + } + Path sumPath = onDisk.resolveSibling("go.sum"); java.io.File sumFile = sumPath.toFile(); if (!sumFile.isFile()) { return new ArrayList<>(); @@ -287,7 +331,7 @@ private static List parseSumSibling(Path goModPath) { /** * Parse go.sum content (string) into the same shape as - * {@link #parseSumSibling(Path)}. Mirrors the Go-side + * {@link #parseSumSibling(Path, Path)}. Mirrors the Go-side * {@code parser.ParseGoSum} for cross-language parity. *

* Malformed lines are logged and skipped — go.sum is best-effort @@ -328,7 +372,7 @@ public static List parseSumContent(@Nullable String content) for (java.util.Map.Entry e : byKey.entrySet()) { String[] parts = e.getKey().split("@", 2); resolved.add(new ResolvedDependency(parts[0], parts[1], e.getValue()[0], e.getValue()[1], - false, false, null, null, null, null)); + false, false, null, null, null, false, null)); } return resolved; } diff --git a/rewrite-go/src/main/java/org/openrewrite/golang/marker/GoResolutionResult.java b/rewrite-go/src/main/java/org/openrewrite/golang/marker/GoResolutionResult.java index 7a7d76beb0..0176595195 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/marker/GoResolutionResult.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/marker/GoResolutionResult.java @@ -30,6 +30,7 @@ import java.util.UUID; import static java.util.Collections.emptyList; +import static org.openrewrite.rpc.RpcReceiveQueue.toEnum; /** * Metadata parsed from a Go module's go.mod (and optionally go.sum) file. @@ -113,6 +114,73 @@ public class GoResolutionResult implements Marker, RpcCodec */ List packageModules; + /** + * How {@link #resolvedDependencies} was derived. Prefer {@link #hasBuildList()} / + * {@link #hasGraph()} over comparing constants. + */ + ResolutionSource resolutionSource; + + /** + * How a module's build list was derived. + */ + public enum ResolutionSource { + /** + * Build list, graph edges and the package-to-module map, from {@code go list -m}, + * {@code go mod graph} and {@code go list -deps}. + */ + TOOLCHAIN, + /** + * Build list from the main module's {@code require} block under Go 1.17+ graph + * pruning. No package-to-module map. + */ + GO_MOD, + /** + * Build list and package-to-module map from {@code vendor/modules.txt}, which is + * authoritative for a vendored build. + */ + VENDOR, + /** + * No build list — {@link #resolvedDependencies} holds go.sum hash rows only. + */ + GO_SUM_ONLY + } + + /** + * Whether the selected rows of {@link #resolvedDependencies} are the modules that + * actually build, rather than a hash inventory. + */ + /** + * A marker that leaves {@link #resolutionSource} unset describes no build list. + */ + private static ResolutionSource sourceOrDefault(@Nullable Object received) { + String name = (String) received; + return name == null || name.isEmpty() ? ResolutionSource.GO_SUM_ONLY : ResolutionSource.valueOf(name); + } + + public boolean hasBuildList() { + return resolutionSource == ResolutionSource.TOOLCHAIN || + resolutionSource == ResolutionSource.VENDOR || + resolutionSource == ResolutionSource.GO_MOD; + } + + /** + * Whether any build-list member carries {@link ResolvedDependency#deps}, i.e. whether + * transitive questions can be answered. Edges are an enrichment over whatever build + * list was derived, so this is a property of the data rather than of + * {@link #resolutionSource}. + */ + public boolean hasGraph() { + if (resolvedDependencies == null) { + return false; + } + for (ResolvedDependency d : resolvedDependencies) { + if (d.getDeps() != null) { + return true; + } + } + return false; + } + public @Nullable Require findRequire(String module) { for (Require r : requires) { if (r.getModulePath().equals(module)) { @@ -165,6 +233,7 @@ public void rpcSend(GoResolutionResult after, RpcSendQueue q) { q.getAndSendListAsRef(after, r -> r.getPackageModules() != null ? r.getPackageModules() : emptyList(), PackageModule::getImportPath, pm -> pm.rpcSend(pm, q)); + q.getAndSend(after, GoResolutionResult::getResolutionSource); } @Override @@ -180,7 +249,8 @@ public GoResolutionResult rpcReceive(GoResolutionResult before, RpcReceiveQueue .withExcludes(q.receiveList(before.excludes, r -> r.rpcReceive(r, q))) .withRetracts(q.receiveList(before.retracts, r -> r.rpcReceive(r, q))) .withResolvedDependencies(q.receiveList(before.resolvedDependencies, r -> r.rpcReceive(r, q))) - .withPackageModules(q.receiveList(before.packageModules, pm -> pm.rpcReceive(pm, q))); + .withPackageModules(q.receiveList(before.packageModules, pm -> pm.rpcReceive(pm, q))) + .withResolutionSource(q.receiveAndGet(before.resolutionSource, GoResolutionResult::sourceOrDefault)); } /** @@ -335,6 +405,13 @@ public static class ResolvedDependency implements RpcCodec { /** This module's own {@code go} directive version, from {@code go list -m}. */ @Nullable String moduleGoVersion; + /** + * Marks a member of the resolved build list. go.sum also records versions MVS + * rejected; those are carried for their hashes with {@code selected} false and + * are not part of the build. + */ + boolean selected; + /** * Direct module dependencies of this node (from {@code go mod graph}), by {@code module@version} * edge reference. Resolve against {@link #resolvedDependencies}. Null when the graph is @@ -354,6 +431,7 @@ public void rpcSend(ResolvedDependency after, RpcSendQueue q) { q.getAndSend(after, ResolvedDependency::getReplacePath); q.getAndSend(after, ResolvedDependency::getReplaceVersion); q.getAndSend(after, ResolvedDependency::getModuleGoVersion); + q.getAndSend(after, ResolvedDependency::isSelected); q.getAndSendListAsRef(after, r -> r.getDeps() != null ? r.getDeps() : emptyList(), ref -> ref.getModulePath() + "@" + ref.getVersion(), ref -> ref.rpcSend(ref, q)); @@ -371,6 +449,7 @@ public ResolvedDependency rpcReceive(ResolvedDependency before, RpcReceiveQueue .withReplacePath(q.receive(before.replacePath)) .withReplaceVersion(q.receive(before.replaceVersion)) .withModuleGoVersion(q.receive(before.moduleGoVersion)) + .withSelected(q.receive(before.selected)) .withDeps(q.receiveList(before.deps, ref -> ref.rpcReceive(ref, q))); } } diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/GoModParserSumHashesTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/GoModParserSumHashesTest.java new file mode 100644 index 0000000000..a8547aeef9 --- /dev/null +++ b/rewrite-go/src/test/java/org/openrewrite/golang/GoModParserSumHashesTest.java @@ -0,0 +1,140 @@ +/* + * 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 org.openrewrite.golang; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openrewrite.golang.marker.GoResolutionResult; +import org.openrewrite.golang.marker.GoResolutionResult.ResolutionSource; +import org.openrewrite.golang.marker.GoResolutionResult.ResolvedDependency; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.openrewrite.Tree; + +import java.util.Arrays; +import java.util.List; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; + +class GoModParserSumHashesTest { + + private static final String GO_SUM = + "github.com/google/uuid v1.6.0 h1:zip=\n" + + "github.com/google/uuid v1.6.0/go.mod h1:mod=\n" + + "golang.org/x/mod v0.27.0 h1:old=\n"; + + private static ResolvedDependency selected(String path, String version) { + return new ResolvedDependency(path, version, null, null, false, false, null, null, null, true, null); + } + + @Test + void relativeSourcePathWithoutProjectRootReadsNothing() { + assertThat(GoModParser.parseSumSibling(Paths.get("go.mod"), null)) + .as("a repo-relative path must not be resolved against the working directory") + .isEmpty(); + } + + @Test + void projectRootTurnsRelativePathIntoLocation(@TempDir Path root) throws IOException { + Files.createDirectories(root.resolve("app")); + Files.write(root.resolve("app/go.sum"), GO_SUM.getBytes(StandardCharsets.UTF_8)); + + List resolved = GoModParser.parseSumSibling(Paths.get("app/go.mod"), root); + + assertThat(resolved).extracting(ResolvedDependency::getModulePath) + .containsExactlyInAnyOrder("github.com/google/uuid", "golang.org/x/mod"); + assertThat(resolved).allSatisfy(d -> + assertThat(d.isSelected()).as("go.sum rows are hashes, not a build list").isFalse()); + } + + @Test + void absoluteSourcePathNeedsNoProjectRoot(@TempDir Path root) throws IOException { + Files.write(root.resolve("go.sum"), GO_SUM.getBytes(StandardCharsets.UTF_8)); + + assertThat(GoModParser.parseSumSibling(root.resolve("go.mod"), null)).hasSize(2); + } + + @Test + void relativeProjectRootResolvesAgainstTheWorkingDirectory(@TempDir Path root) throws IOException { + Files.write(root.resolve("go.sum"), GO_SUM.getBytes(StandardCharsets.UTF_8)); + Path relativeRoot = Paths.get("").toAbsolutePath().relativize(root); + + assertThat(GoModParser.parseSumSibling(Paths.get("go.mod"), relativeRoot)) + .as("supplying a root, even a relative one, states where the repo is") + .hasSize(2); + } + + @Test + void mergeJoinsHashesOntoBuildListAndKeepsItSelected() { + List buildList = singletonList(selected("github.com/google/uuid", "v1.6.0")); + + List merged = GoModParser.mergeSumHashes(buildList, GoModParser.parseSumContent(GO_SUM)); + + assertThat(merged).filteredOn(ResolvedDependency::isSelected).singleElement().satisfies(d -> { + assertThat(d.getModulePath()).isEqualTo("github.com/google/uuid"); + assertThat(d.getModuleHash()).isEqualTo("h1:zip="); + assertThat(d.getGoModHash()).isEqualTo("h1:mod="); + }); + } + + @Test + void mergeKeepsUnmatchedSumRowsUnselected() { + List buildList = Arrays.asList( + selected("github.com/google/uuid", "v1.6.0"), + selected("golang.org/x/mod", "v0.35.0")); + + List merged = GoModParser.mergeSumHashes(buildList, GoModParser.parseSumContent(GO_SUM)); + + assertThat(merged).filteredOn(d -> !d.isSelected()).singleElement().satisfies(d -> { + assertThat(d.getModulePath()).isEqualTo("golang.org/x/mod"); + assertThat(d.getVersion()).isEqualTo("v0.27.0"); + }); + } + + @Test + void hasGraphToleratesAbsentResolvedDependencies() { + GoResolutionResult marker = new GoResolutionResult(Tree.randomId(), "example.com/foo", null, null, + "go.mod", emptyList(), emptyList(), emptyList(), emptyList(), null, emptyList(), + ResolutionSource.GO_MOD); + + assertThat(marker.hasGraph()).isFalse(); + } + + @Test + void hasGraphIsDrivenByEdgesNotProvenance() { + ResolvedDependency withEdges = new ResolvedDependency("a", "v1", null, null, false, false, null, null, null, + true, singletonList(new GoResolutionResult.ModuleRef("b", "v2"))); + GoResolutionResult marker = new GoResolutionResult(Tree.randomId(), "example.com/foo", null, null, + "go.mod", emptyList(), emptyList(), emptyList(), emptyList(), singletonList(withEdges), emptyList(), + ResolutionSource.GO_MOD); + + assertThat(marker.hasGraph()).as("a go.mod build list enriched from the module cache has edges").isTrue(); + } + + @Test + void mergeWithoutBuildListYieldsHashInventory() { + List fromSum = GoModParser.parseSumContent(GO_SUM); + + assertThat(GoModParser.mergeSumHashes(emptyList(), fromSum)).isEqualTo(fromSum); + assertThat(GoModParser.mergeSumHashes(null, fromSum)).isEqualTo(fromSum); + } +}