From b66a0ce31494355e71d2b2bd670bad5116badd0c Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Wed, 19 Aug 2026 02:05:13 +0200 Subject: [PATCH 1/3] Derive a Go build list from go.mod when the toolchain graph is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GoResolutionResult.resolvedDependencies` previously held whatever go.sum recorded whenever parse-time toolchain resolution failed. go.sum is a hash inventory of the module graph, not a build list: it deliberately includes versions MVS rejected, so a recipe reading it reports modules that are not in the build at all. Under Go 1.17+ module graph pruning the main module's expanded `require` block names every module providing a transitively imported package, so it is the build list — derivable with no toolchain, no module cache and no network. `resolvedDependencies` now means the build list, with go.sum joined in by (path, version) for hashes and rows matching nothing kept unselected. Two fields let recipes tell these states apart instead of inferring them: - `ResolutionSource` (TOOLCHAIN / GO_MOD / GO_SUM_ONLY) with `hasBuildList()` and `hasGraph()`. Recipes use the helpers, so later sources can be added without touching call sites. - `ResolvedDependency.selected`, distinguishing build-list members from go.sum rows for versions that lost. `GoModParser.withSumHashes` replaced `resolvedDependencies` wholesale, which was harmless only while the Go side always returned it empty. It now merges. Its sibling lookup also resolved a repo-relative source path against the process working directory, so a test run would read whatever go.sum sat there — for rewrite-go's own suite, its own, injecting `github.com/creack/pty` into fixtures that never mention it. `GoModConformanceTest` already routed around this with a virtual source path. The lookup now resolves through `relativeTo`, which is what turns a repo-relative identifier back into a location, and skips the read when no absolute path can be formed. --- rewrite-go/cmd/rpc/main.go | 7 +- rewrite-go/pkg/parser/gomod_buildlist.go | 67 ++++++++++ rewrite-go/pkg/parser/gomod_buildlist_test.go | 126 ++++++++++++++++++ rewrite-go/pkg/parser/gomod_resolve.go | 6 +- .../pkg/rpc/go_resolution_result_codec.go | 6 + rewrite-go/pkg/rpc/marker_codec_test.go | 5 +- .../pkg/tree/golang/go_resolution_result.go | 35 +++++ .../golang/rpc/MarkerRoundTripTest.java | 20 ++- .../org/openrewrite/golang/GoModParser.java | 71 ++++++++-- .../golang/marker/GoResolutionResult.java | 56 +++++++- .../golang/GoModParserSumHashesTest.java | 106 +++++++++++++++ 11 files changed, 480 insertions(+), 25 deletions(-) create mode 100644 rewrite-go/pkg/parser/gomod_buildlist.go create mode 100644 rewrite-go/pkg/parser/gomod_buildlist_test.go create mode 100644 rewrite-go/src/test/java/org/openrewrite/golang/GoModParserSumHashesTest.java diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index 13f816717e3..5e2dd0a8bf2 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, nil) gm.Markers.Entries = append(gm.Markers.Entries, *mrr) } goModByIdx[r.idx] = gm @@ -2431,10 +2434,12 @@ 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 (deriving build list from go.mod): %v", moduleDir, rerr) + mrr.ResolvedDependencies, mrr.ResolutionSource = goparser.DeriveBuildList(mrr.GoVersion, mrr.Requires, 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 00000000000..8bd863cb903 --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_buildlist.go @@ -0,0 +1,67 @@ +/* + * 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, 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 { + buildList = append(buildList, golang.GoResolvedDependency{ + ModulePath: r.ModulePath, + Version: r.Version, + Indirect: r.Indirect, + }) + } + return MergeResolvedDependencies(fromSum, buildList), golang.ResolutionGoMod +} + +// 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 00000000000..75de290a926 --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_buildlist_test.go @@ -0,0 +1,126 @@ +/* + * 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) + + 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, 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) + + 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, 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, 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) + + 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) + } +} diff --git a/rewrite-go/pkg/parser/gomod_resolve.go b/rewrite-go/pkg/parser/gomod_resolve.go index faee6a38427..e5713ed2140 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/rpc/go_resolution_result_codec.go b/rewrite-go/pkg/rpc/go_resolution_result_codec.go index e37b2775431..0bad25453cd 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 5f4eb284e80..b567aa4bb75 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 8285dd752aa..5d524fddb42 100644 --- a/rewrite-go/pkg/tree/golang/go_resolution_result.go +++ b/rewrite-go/pkg/tree/golang/go_resolution_result.go @@ -39,6 +39,36 @@ 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 graph edges, no package->module map. + ResolutionGoMod GoResolutionSource = "GO_MOD" + // 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 { + return m.ResolutionSource == ResolutionToolchain || m.ResolutionSource == ResolutionGoMod +} + +// HasGraph reports whether GoResolvedDependency.Deps is populated, i.e. whether +// transitive questions can be answered. +func (m GoResolutionResult) HasGraph() bool { + return m.ResolutionSource == ResolutionToolchain } func (m GoResolutionResult) ID() uuid.UUID { return m.Ident } @@ -112,6 +142,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 +187,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 91421fb4785..3c4acbdabe9 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 5226ae8d096..85e6c2ecaab 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,16 @@ 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. + */ + static List parseSumSibling(Path goModPath, @Nullable Path relativeTo) { + Path onDisk = relativeTo == null ? goModPath : relativeTo.resolve(goModPath); + if (!onDisk.isAbsolute()) { + return new ArrayList<>(); + } + Path sumPath = onDisk.resolveSibling("go.sum"); java.io.File sumFile = sumPath.toFile(); if (!sumFile.isFile()) { return new ArrayList<>(); @@ -328,7 +371,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 7a7d76beb06..0a490d23aa8 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,48 @@ 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 graph edges, no package-to-module map. + */ + GO_MOD, + /** + * 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. + */ + public boolean hasBuildList() { + return resolutionSource == ResolutionSource.TOOLCHAIN || resolutionSource == ResolutionSource.GO_MOD; + } + + /** + * Whether {@link ResolvedDependency#deps} is populated, i.e. whether transitive + * questions can be answered. + */ + public boolean hasGraph() { + return resolutionSource == ResolutionSource.TOOLCHAIN; + } + public @Nullable Require findRequire(String module) { for (Require r : requires) { if (r.getModulePath().equals(module)) { @@ -165,6 +208,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 +224,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, toEnum(ResolutionSource.class))); } /** @@ -335,6 +380,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 +406,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 +424,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 00000000000..419a709c7c6 --- /dev/null +++ b/rewrite-go/src/test/java/org/openrewrite/golang/GoModParserSumHashesTest.java @@ -0,0 +1,106 @@ +/* + * 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.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 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 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 mergeWithoutBuildListYieldsHashInventory() { + List fromSum = GoModParser.parseSumContent(GO_SUM); + + assertThat(GoModParser.mergeSumHashes(emptyList(), fromSum)).isEqualTo(fromSum); + assertThat(GoModParser.mergeSumHashes(null, fromSum)).isEqualTo(fromSum); + } +} From 5e9fa28300c8de18ade5280b841fb0c3247fadc0 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Wed, 19 Aug 2026 02:11:52 +0200 Subject: [PATCH 2/3] Populate the Go module graph offline from vendor/ and the module cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build list derived from go.mod carries no edges, so transitive dependency questions still needed the toolchain. Two offline sources close that gap. vendor/modules.txt is authoritative for a vendored build and is the only offline source of the package-to-module map — in Go an import path is not a module coordinate, so that mapping cannot be recovered from go.mod. It ranks above the go.mod-derived build list, below the toolchain. The module cache already holds each dependency's own go.mod at cache/download//@v/.mod, and its requires are that module's edges — the same set `go mod graph` prints. Because the build list has already selected every version, attaching edges needs no version resolution. Modules absent from a partially warm cache keep nil edges, which is distinct from an empty slice: no edges known, rather than no dependencies. Edges are an enrichment over whatever build list was derived, not a source of one: a vendored module with a warm cache has both. `hasGraph()` therefore reads the data rather than `resolutionSource`, and the cache is not an enum constant. `resolutionSource` means only where the build list came from. --- rewrite-go/cmd/rpc/main.go | 16 ++- rewrite-go/pkg/parser/gomod_cachegraph.go | 92 +++++++++++++ .../pkg/parser/gomod_cachegraph_test.go | 129 ++++++++++++++++++ rewrite-go/pkg/parser/gomod_vendor.go | 104 ++++++++++++++ rewrite-go/pkg/parser/gomod_vendor_test.go | 117 ++++++++++++++++ .../pkg/tree/golang/go_resolution_result.go | 23 +++- .../golang/marker/GoResolutionResult.java | 22 ++- 7 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 rewrite-go/pkg/parser/gomod_cachegraph.go create mode 100644 rewrite-go/pkg/parser/gomod_cachegraph_test.go create mode 100644 rewrite-go/pkg/parser/gomod_vendor.go create mode 100644 rewrite-go/pkg/parser/gomod_vendor_test.go diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index 5e2dd0a8bf2..0aa8be5f054 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -2434,8 +2434,20 @@ 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 (deriving build list from go.mod): %v", moduleDir, rerr) - mrr.ResolvedDependencies, mrr.ResolutionSource = goparser.DeriveBuildList(mrr.GoVersion, mrr.Requires, mrr.ResolvedDependencies) + 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.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 diff --git a/rewrite-go/pkg/parser/gomod_cachegraph.go b/rewrite-go/pkg/parser/gomod_cachegraph.go new file mode 100644 index 00000000000..758352d3b7a --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_cachegraph.go @@ -0,0 +1,92 @@ +/* + * 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" + + "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. + escaped, err := module.EscapePath(modulePath) + if err != nil { + return nil, false + } + path := filepath.Join(cacheDir, "cache", "download", escaped, "@v", version+".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 != "" { + return filepath.Join(gopath, "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 00000000000..b140f1aa219 --- /dev/null +++ b/rewrite-go/pkg/parser/gomod_cachegraph_test.go @@ -0,0 +1,129 @@ +/* + * 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" + "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) +} diff --git a/rewrite-go/pkg/parser/gomod_vendor.go b/rewrite-go/pkg/parser/gomod_vendor.go new file mode 100644 index 00000000000..27b56fd307b --- /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 00000000000..bd017692a9b --- /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/tree/golang/go_resolution_result.go b/rewrite-go/pkg/tree/golang/go_resolution_result.go index 5d524fddb42..1b2e795130f 100644 --- a/rewrite-go/pkg/tree/golang/go_resolution_result.go +++ b/rewrite-go/pkg/tree/golang/go_resolution_result.go @@ -54,6 +54,9 @@ const ( // ResolutionGoMod: build list from the main module's require block under // Go 1.17+ pruning. No graph edges, 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. No graph edges. + ResolutionVendor GoResolutionSource = "VENDOR" // ResolutionGoSumOnly: no build list — ResolvedDependencies holds go.sum // hash rows only. ResolutionGoSumOnly GoResolutionSource = "GO_SUM_ONLY" @@ -62,13 +65,25 @@ const ( // HasBuildList reports whether ResolvedDependencies' selected rows are the // modules that actually build, rather than a go.sum hash inventory. func (m GoResolutionResult) HasBuildList() bool { - return m.ResolutionSource == ResolutionToolchain || m.ResolutionSource == ResolutionGoMod + switch m.ResolutionSource { + case ResolutionToolchain, ResolutionVendor, ResolutionGoMod: + return true + default: + return false + } } -// HasGraph reports whether GoResolvedDependency.Deps is populated, i.e. whether -// transitive questions can be answered. +// 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 { - return m.ResolutionSource == ResolutionToolchain + for _, d := range m.ResolvedDependencies { + if d.Deps != nil { + return true + } + } + return false } func (m GoResolutionResult) ID() uuid.UUID { return m.Ident } 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 0a490d23aa8..79b5d6ec7cf 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 @@ -134,6 +134,11 @@ public enum ResolutionSource { * pruning. No graph edges, 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. No graph edges. + */ + VENDOR, /** * No build list — {@link #resolvedDependencies} holds go.sum hash rows only. */ @@ -145,15 +150,24 @@ public enum ResolutionSource { * actually build, rather than a hash inventory. */ public boolean hasBuildList() { - return resolutionSource == ResolutionSource.TOOLCHAIN || resolutionSource == ResolutionSource.GO_MOD; + return resolutionSource == ResolutionSource.TOOLCHAIN || + resolutionSource == ResolutionSource.VENDOR || + resolutionSource == ResolutionSource.GO_MOD; } /** - * Whether {@link ResolvedDependency#deps} is populated, i.e. whether transitive - * questions can be answered. + * 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() { - return resolutionSource == ResolutionSource.TOOLCHAIN; + for (ResolvedDependency d : resolvedDependencies) { + if (d.getDeps() != null) { + return true; + } + } + return false; } public @Nullable Require findRequire(String module) { From f3853cd7454e5db81e49e8c69c9fae28599e3911 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Wed, 19 Aug 2026 09:21:19 +0200 Subject: [PATCH 3/3] Address review findings in the offline Go module graph - The module cache escapes versions as well as paths, so a module at a version containing uppercase (v1.0.0-RC1 is stored as v1.0.0-!r!c1.mod) never resolved and silently read as having no known edges. - `DeriveBuildList` ignored `replace`, so a forked dependency landed selected under its original coordinate with no ReplacePath, inverted from what the toolchain path reports. `exclude` stays unapplied: under pruning the require block already reflects it. - `hasGraph()` iterated `resolvedDependencies` unguarded while every other access in the class treats it as nullable, including the receive path, where `receiveList` yields null for a DELETE. - A marker predating `resolutionSource` arrives with it unset; `Enum.valueOf` on the empty string failed the whole RPC exchange rather than degrading to GO_SUM_ONLY. - GOPATH is a list, and the module cache lives under its first entry. - `parseSumSibling` skipped the read for any non-absolute path, dropping hashes when a caller passed a relative project root. A supplied root states where the repo is, so it resolves against the working directory; a bare source path without one remains an identifier and is not a location. - GO_MOD and VENDOR no longer claim to have no graph edges, since cache enrichment applies to both. --- rewrite-go/cmd/rpc/main.go | 4 +- rewrite-go/pkg/parser/gomod_buildlist.go | 26 ++++++++-- rewrite-go/pkg/parser/gomod_buildlist_test.go | 47 ++++++++++++++++--- rewrite-go/pkg/parser/gomod_cachegraph.go | 15 ++++-- .../pkg/parser/gomod_cachegraph_test.go | 24 ++++++++++ .../pkg/tree/golang/go_resolution_result.go | 4 +- .../org/openrewrite/golang/GoModParser.java | 7 +-- .../golang/marker/GoResolutionResult.java | 17 +++++-- .../golang/GoModParserSumHashesTest.java | 34 ++++++++++++++ 9 files changed, 155 insertions(+), 23 deletions(-) diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index 0aa8be5f054..5e1a80ce61e 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -728,7 +728,7 @@ func (s *server) handleParse(params json.RawMessage) (any, *rpcError) { 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, nil) + 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 @@ -2443,7 +2443,7 @@ func (s *server) handleParseProject(params json.RawMessage) (any, *rpcError) { mrr.PackageModules = vendorPkgs mrr.ResolutionSource = golang.ResolutionVendor } else { - mrr.ResolvedDependencies, mrr.ResolutionSource = goparser.DeriveBuildList(mrr.GoVersion, mrr.Requires, mrr.ResolvedDependencies) + 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. diff --git a/rewrite-go/pkg/parser/gomod_buildlist.go b/rewrite-go/pkg/parser/gomod_buildlist.go index 8bd863cb903..82841cde207 100644 --- a/rewrite-go/pkg/parser/gomod_buildlist.go +++ b/rewrite-go/pkg/parser/gomod_buildlist.go @@ -32,7 +32,7 @@ const pruningMinGoVersion = "v1.17" // 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, fromSum []golang.GoResolvedDependency) ([]golang.GoResolvedDependency, golang.GoResolutionSource) { +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 { @@ -44,15 +44,35 @@ func DeriveBuildList(goVersion string, requires []golang.GoRequire, fromSum []go buildList := make([]golang.GoResolvedDependency, 0, len(requires)) for _, r := range requires { - buildList = append(buildList, golang.GoResolvedDependency{ + 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 { diff --git a/rewrite-go/pkg/parser/gomod_buildlist_test.go b/rewrite-go/pkg/parser/gomod_buildlist_test.go index 75de290a926..044a3c7d60b 100644 --- a/rewrite-go/pkg/parser/gomod_buildlist_test.go +++ b/rewrite-go/pkg/parser/gomod_buildlist_test.go @@ -35,7 +35,7 @@ func TestDeriveBuildListFromPrunedRequires(t *testing.T) { {ModulePath: "golang.org/x/mod", Version: "v0.35.0", Indirect: true}, } - list, source := DeriveBuildList("1.25.0", requires, nil) + list, source := DeriveBuildList("1.25.0", requires, nil, nil) assert.Equal(t, golang.ResolutionGoMod, source) require.Len(t, list, 2) @@ -51,7 +51,7 @@ 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, sum) + list, source := DeriveBuildList("1.16", requires, nil, sum) assert.Equal(t, golang.ResolutionGoSumOnly, source) require.Len(t, list, 1) @@ -61,7 +61,7 @@ func TestDeriveBuildListPrePruningFallsBackToGoSumOnly(t *testing.T) { func TestDeriveBuildListMissingGoDirectiveFallsBackToGoSumOnly(t *testing.T) { requires := []golang.GoRequire{{ModulePath: "github.com/google/uuid", Version: "v1.6.0"}} - _, source := DeriveBuildList("", requires, nil) + _, source := DeriveBuildList("", requires, nil, nil) assert.Equal(t, golang.ResolutionGoSumOnly, source) } @@ -72,7 +72,7 @@ func TestDeriveBuildListJoinsGoSumHashes(t *testing.T) { {ModulePath: "github.com/google/uuid", Version: "v1.6.0", ModuleHash: "h1:zip", GoModHash: "h1:mod"}, } - list, _ := DeriveBuildList("1.21", requires, sum) + list, _ := DeriveBuildList("1.21", requires, nil, sum) require.Len(t, list, 1) assert.Equal(t, "h1:zip", list[0].ModuleHash) @@ -87,7 +87,7 @@ func TestDeriveBuildListMarksRejectedGoSumVersionsUnselected(t *testing.T) { sumRow("golang.org/x/mod", "v0.27.0", "h1:old"), } - list, source := DeriveBuildList("1.21", requires, sum) + list, source := DeriveBuildList("1.21", requires, nil, sum) assert.Equal(t, golang.ResolutionGoMod, source) require.Len(t, list, 2) @@ -101,7 +101,7 @@ func TestDeriveBuildListMarksRejectedGoSumVersionsUnselected(t *testing.T) { } func TestDeriveBuildListEmptyRequires(t *testing.T) { - list, source := DeriveBuildList("1.21", nil, nil) + 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") @@ -124,3 +124,38 @@ func TestSupportsPruning(t *testing.T) { 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 index 758352d3b7a..30daf8524f0 100644 --- a/rewrite-go/pkg/parser/gomod_cachegraph.go +++ b/rewrite-go/pkg/parser/gomod_cachegraph.go @@ -19,6 +19,7 @@ package parser import ( "os" "path/filepath" + "strings" "golang.org/x/mod/modfile" "golang.org/x/mod/module" @@ -56,12 +57,16 @@ func AttachCachedEdges(cacheDir string, buildList []golang.GoResolvedDependency) // 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. - escaped, err := module.EscapePath(modulePath) + // survives case-insensitive filesystems. Versions carry the same encoding. + escapedPath, err := module.EscapePath(modulePath) if err != nil { return nil, false } - path := filepath.Join(cacheDir, "cache", "download", escaped, "@v", version+".mod") + 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 @@ -82,7 +87,9 @@ func GoModCacheDir() string { return dir } if gopath := os.Getenv("GOPATH"); gopath != "" { - return filepath.Join(gopath, "pkg", "mod") + // 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 { diff --git a/rewrite-go/pkg/parser/gomod_cachegraph_test.go b/rewrite-go/pkg/parser/gomod_cachegraph_test.go index b140f1aa219..4a7096f6557 100644 --- a/rewrite-go/pkg/parser/gomod_cachegraph_test.go +++ b/rewrite-go/pkg/parser/gomod_cachegraph_test.go @@ -19,6 +19,7 @@ package parser import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -127,3 +128,26 @@ require golang.org/x/tools v0.43.0 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/tree/golang/go_resolution_result.go b/rewrite-go/pkg/tree/golang/go_resolution_result.go index 1b2e795130f..6351fb5312b 100644 --- a/rewrite-go/pkg/tree/golang/go_resolution_result.go +++ b/rewrite-go/pkg/tree/golang/go_resolution_result.go @@ -52,10 +52,10 @@ const ( // 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 graph edges, no package->module map. + // 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. No graph edges. + // which is authoritative for a vendored build. ResolutionVendor GoResolutionSource = "VENDOR" // ResolutionGoSumOnly: no build list — ResolvedDependencies holds go.sum // hash rows only. 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 85e6c2ecaab..ad4e6ec852d 100644 --- a/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java +++ b/rewrite-go/src/main/java/org/openrewrite/golang/GoModParser.java @@ -307,10 +307,11 @@ private static void parseBlockEntry(BlockState block, String rawLine, String lin /** * 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. + * 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); + Path onDisk = relativeTo == null ? goModPath : relativeTo.resolve(goModPath).toAbsolutePath(); if (!onDisk.isAbsolute()) { return new ArrayList<>(); } @@ -330,7 +331,7 @@ static List parseSumSibling(Path goModPath, @Nullable Path r /** * 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 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 79b5d6ec7cf..01765951950 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 @@ -131,12 +131,12 @@ public enum ResolutionSource { TOOLCHAIN, /** * Build list from the main module's {@code require} block under Go 1.17+ graph - * pruning. No graph edges, no package-to-module map. + * 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. No graph edges. + * authoritative for a vendored build. */ VENDOR, /** @@ -149,6 +149,14 @@ public enum ResolutionSource { * 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 || @@ -162,6 +170,9 @@ public boolean hasBuildList() { * {@link #resolutionSource}. */ public boolean hasGraph() { + if (resolvedDependencies == null) { + return false; + } for (ResolvedDependency d : resolvedDependencies) { if (d.getDeps() != null) { return true; @@ -239,7 +250,7 @@ public GoResolutionResult rpcReceive(GoResolutionResult before, RpcReceiveQueue .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))) - .withResolutionSource(q.receiveAndGet(before.resolutionSource, toEnum(ResolutionSource.class))); + .withResolutionSource(q.receiveAndGet(before.resolutionSource, GoResolutionResult::sourceOrDefault)); } /** diff --git a/rewrite-go/src/test/java/org/openrewrite/golang/GoModParserSumHashesTest.java b/rewrite-go/src/test/java/org/openrewrite/golang/GoModParserSumHashesTest.java index 419a709c7c6..a8547aeef96 100644 --- a/rewrite-go/src/test/java/org/openrewrite/golang/GoModParserSumHashesTest.java +++ b/rewrite-go/src/test/java/org/openrewrite/golang/GoModParserSumHashesTest.java @@ -17,6 +17,8 @@ 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; @@ -24,6 +26,8 @@ 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; @@ -69,6 +73,16 @@ void absoluteSourcePathNeedsNoProjectRoot(@TempDir Path root) throws IOException 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")); @@ -96,6 +110,26 @@ void mergeKeepsUnmatchedSumRowsUnselected() { }); } + @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);