diff --git a/pkg/commands/internal/builder.go b/pkg/commands/internal/builder.go index 5bb9b472fac2..ebbc11aaed7a 100644 --- a/pkg/commands/internal/builder.go +++ b/pkg/commands/internal/builder.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" "io" "os" @@ -115,6 +116,11 @@ func (b Builder) addToGoMod(ctx context.Context) error { return err } + err = b.mergeReplaceDirectives(ctx, plugin.Path) + if err != nil { + return err + } + continue } @@ -162,6 +168,89 @@ func (b Builder) addReplaceDirective(ctx context.Context, plugin *Plugin) error return nil } +type goModReplace struct { + Replace []struct { + Old struct{ Path, Version string } + New struct{ Path, Version string } + } +} + +func readGoModReplace(ctx context.Context, dir string) (*goModReplace, error) { + cmd := exec.CommandContext(ctx, "go", "mod", "edit", "-json") + cmd.Dir = dir + + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(cmd.Args, " "), err) + } + + var goMod goModReplace + err = json.Unmarshal(output, &goMod) + if err != nil { + return nil, fmt.Errorf("unmarshal go.mod json: %w", err) + } + + return &goMod, nil +} + +func (b Builder) mergeReplaceDirectives(ctx context.Context, pluginPath string) error { + pluginGoMod, err := readGoModReplace(ctx, pluginPath) + if err != nil { + return err + } + + rootGoMod, err := readGoModReplace(ctx, b.repo) + if err != nil { + return err + } + + for _, r := range pluginGoMod.Replace { + for _, rr := range rootGoMod.Replace { + if r.Old.Path == rr.Old.Path && r.Old.Version == rr.Old.Version { + return fmt.Errorf("duplicate replace directive for %s@%s", r.Old.Path, r.Old.Version) + } + } + } + + for _, r := range pluginGoMod.Replace { + abs, err := filepath.Abs(filepath.Join(pluginPath, r.New.Path)) + if err != nil { + return fmt.Errorf("get absolute path: %w", err) + } + + stat, err := os.Stat(abs) + if err != nil { + return fmt.Errorf("%s: %w", abs, err) + } + if stat.IsDir() { + r.New.Path = abs + } + + replace := fmt.Sprintf("%s=%s", r.Old.Path, r.New.Path) + if r.Old.Version != "" { + replace += "@" + r.Old.Version + } + if r.New.Version != "" { + replace += "@" + r.New.Version + } + + cmd := exec.CommandContext(ctx, "go", "mod", "edit", "-replace", replace) + cmd.Dir = b.repo + + output, err := cmd.CombinedOutput() + if err != nil { + b.log.Warnf("%s", string(output)) + + return fmt.Errorf("%s: %w", strings.Join(cmd.Args, " "), err) + } + } + + // NOTE(sublee): We don't need to follow nested replace directives because + // the directives are effective only at the main module. All necessary + // replacements should be handled at this level. + return nil +} + func (b Builder) goModTidy(ctx context.Context) error { cmd := exec.CommandContext(ctx, "go", "mod", "tidy") cmd.Dir = b.repo diff --git a/pkg/commands/internal/builder_test.go b/pkg/commands/internal/builder_test.go index 99a1cad0e0c1..6f705e2f72bb 100644 --- a/pkg/commands/internal/builder_test.go +++ b/pkg/commands/internal/builder_test.go @@ -1,9 +1,14 @@ package internal import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_sanitizeVersion(t *testing.T) { @@ -54,3 +59,107 @@ func Test_sanitizeVersion(t *testing.T) { }) } } + +func TestMergeReplaceDirectives(t *testing.T) { + t.Parallel() + + // Create a temporary module with the following structure: + // tmp/ + // go.mod + // golangci-lint/ + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "go.mod"), []byte(` +module github.com/golangci/golangci-lint/v2 +go 1.24.0 +`), 0o600)) + require.NoError(t, os.Mkdir(filepath.Join(tmp, "golangci-lint"), 0o700)) + + b := NewBuilder(nil, &Configuration{Plugins: []*Plugin{ + {Module: "example.com/plugin", Path: "testdata/plugin"}, + }}, tmp) + + // Merge replace directives from the plugin's go.mod into the temporary + // repo. Only the plugin's own replace rules are applied; transitive + // replaces from its dependencies are not automatically merged. + err := b.mergeReplaceDirectives(t.Context(), filepath.Join("testdata", "plugin")) + require.NoError(t, err) + + cmd := exec.CommandContext(t.Context(), "go", "mod", "edit", "-json") + cmd.Dir = b.repo + output, err := cmd.CombinedOutput() + require.NoError(t, err) + + var goMod struct { + Replace []struct{ New struct{ Path string } } + } + err = json.Unmarshal(output, &goMod) + require.NoError(t, err) + + // The go.mod file should include a replace directive for + // example.com/target, pointing to the local path, because + // example.com/plugin's go.mod defines it. However, it should not include a + // replace directive for example.com/other, since example.com/plugin does + // not directly depend on it, and go mod ignores such transitive + // replacements. + require.Len(t, goMod.Replace, 1) + assert.Contains(t, goMod.Replace[0].New.Path, "testdata/plugin/target") +} + +func TestMergeReplaceDirectives_DuplicateReplace(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "go.mod"), []byte(` +module github.com/golangci/golangci-lint/v2 +go 1.24.0 +`), 0o600)) + require.NoError(t, os.Mkdir(filepath.Join(tmp, "golangci-lint"), 0o700)) + + // Both plugins define a replace directive for example.com/target. + b := NewBuilder(nil, &Configuration{Plugins: []*Plugin{ + {Module: "example.com/plugin", Path: "testdata/plugin"}, + {Module: "example.com/plugin2", Path: "testdata/plugin2"}, + }}, tmp) + + err := b.mergeReplaceDirectives(t.Context(), filepath.Join("testdata", "plugin")) + require.NoError(t, err) + + err = b.mergeReplaceDirectives(t.Context(), filepath.Join("testdata", "plugin2")) + assert.ErrorContains(t, err, "duplicate replace directive for example.com/target") +} + +func TestMergeReplaceDirectives_DuplicateButDifferentVersion(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "go.mod"), []byte(` +module github.com/golangci/golangci-lint/v2 +go 1.24.0 +`), 0o600)) + require.NoError(t, os.Mkdir(filepath.Join(tmp, "golangci-lint"), 0o700)) + + // Both plugins define a replace directive for example.com/target. + b := NewBuilder(nil, &Configuration{Plugins: []*Plugin{ + {Module: "example.com/plugin", Path: "testdata/plugin"}, + {Module: "example.com/plugin3", Path: "testdata/plugin3"}, + }}, tmp) + + err := b.mergeReplaceDirectives(t.Context(), filepath.Join("testdata", "plugin")) + require.NoError(t, err) + + err = b.mergeReplaceDirectives(t.Context(), filepath.Join("testdata", "plugin3")) + require.NoError(t, err) + + cmd := exec.CommandContext(t.Context(), "go", "mod", "edit", "-json") + cmd.Dir = b.repo + output, err := cmd.CombinedOutput() + require.NoError(t, err) + + var goMod struct { + Replace []struct{ New struct{ Path string } } + } + err = json.Unmarshal(output, &goMod) + require.NoError(t, err) + + assert.Len(t, goMod.Replace, 2) +} diff --git a/pkg/commands/internal/testdata/plugin/go.mod b/pkg/commands/internal/testdata/plugin/go.mod new file mode 100644 index 000000000000..1d160853413c --- /dev/null +++ b/pkg/commands/internal/testdata/plugin/go.mod @@ -0,0 +1,7 @@ +module example.com/plugin + +go 1.24.0 + +require example.com/target v0.0.0 + +replace example.com/target => ./target diff --git a/pkg/commands/internal/testdata/plugin/plugin.go b/pkg/commands/internal/testdata/plugin/plugin.go new file mode 100644 index 000000000000..a4a5a011af84 --- /dev/null +++ b/pkg/commands/internal/testdata/plugin/plugin.go @@ -0,0 +1,3 @@ +package plugin + +import _ "example.com/target" diff --git a/pkg/commands/internal/testdata/plugin/target/go.mod b/pkg/commands/internal/testdata/plugin/target/go.mod new file mode 100644 index 000000000000..db2767fa103b --- /dev/null +++ b/pkg/commands/internal/testdata/plugin/target/go.mod @@ -0,0 +1,7 @@ +module example.com/target + +go 1.24.0 + +require example.com/other v0.0.0 + +replace example.com/other => ./other diff --git a/pkg/commands/internal/testdata/plugin/target/other/go.mod b/pkg/commands/internal/testdata/plugin/target/other/go.mod new file mode 100644 index 000000000000..c85f9d420586 --- /dev/null +++ b/pkg/commands/internal/testdata/plugin/target/other/go.mod @@ -0,0 +1,3 @@ +module example.com/other + +go 1.24.0 diff --git a/pkg/commands/internal/testdata/plugin/target/other/other.go b/pkg/commands/internal/testdata/plugin/target/other/other.go new file mode 100644 index 000000000000..58a9531fc5e2 --- /dev/null +++ b/pkg/commands/internal/testdata/plugin/target/other/other.go @@ -0,0 +1 @@ +package other diff --git a/pkg/commands/internal/testdata/plugin/target/target.go b/pkg/commands/internal/testdata/plugin/target/target.go new file mode 100644 index 000000000000..ba7182c4b5e8 --- /dev/null +++ b/pkg/commands/internal/testdata/plugin/target/target.go @@ -0,0 +1,3 @@ +package target + +import _ "example.com/other" diff --git a/pkg/commands/internal/testdata/plugin2/go.mod b/pkg/commands/internal/testdata/plugin2/go.mod new file mode 100644 index 000000000000..f306d1814e5d --- /dev/null +++ b/pkg/commands/internal/testdata/plugin2/go.mod @@ -0,0 +1,7 @@ +module example.com/plugin2 + +go 1.24.0 + +require example.com/target v0.0.0 + +replace example.com/target => ./target diff --git a/pkg/commands/internal/testdata/plugin2/plugin.go b/pkg/commands/internal/testdata/plugin2/plugin.go new file mode 100644 index 000000000000..a4a5a011af84 --- /dev/null +++ b/pkg/commands/internal/testdata/plugin2/plugin.go @@ -0,0 +1,3 @@ +package plugin + +import _ "example.com/target" diff --git a/pkg/commands/internal/testdata/plugin2/target/go.mod b/pkg/commands/internal/testdata/plugin2/target/go.mod new file mode 100644 index 000000000000..eb0bd88b862c --- /dev/null +++ b/pkg/commands/internal/testdata/plugin2/target/go.mod @@ -0,0 +1,3 @@ +module example.com/target + +go 1.24.0 \ No newline at end of file diff --git a/pkg/commands/internal/testdata/plugin2/target/target.go b/pkg/commands/internal/testdata/plugin2/target/target.go new file mode 100644 index 000000000000..ec6c15d2ee17 --- /dev/null +++ b/pkg/commands/internal/testdata/plugin2/target/target.go @@ -0,0 +1 @@ +package target diff --git a/pkg/commands/internal/testdata/plugin3/go.mod b/pkg/commands/internal/testdata/plugin3/go.mod new file mode 100644 index 000000000000..36506a4be000 --- /dev/null +++ b/pkg/commands/internal/testdata/plugin3/go.mod @@ -0,0 +1,7 @@ +module example.com/plugin2 + +go 1.24.0 + +require example.com/target v1.0.0 + +replace example.com/target@v1.0.0 => ./target diff --git a/pkg/commands/internal/testdata/plugin3/plugin.go b/pkg/commands/internal/testdata/plugin3/plugin.go new file mode 100644 index 000000000000..a4a5a011af84 --- /dev/null +++ b/pkg/commands/internal/testdata/plugin3/plugin.go @@ -0,0 +1,3 @@ +package plugin + +import _ "example.com/target" diff --git a/pkg/commands/internal/testdata/plugin3/target/go.mod b/pkg/commands/internal/testdata/plugin3/target/go.mod new file mode 100644 index 000000000000..eb0bd88b862c --- /dev/null +++ b/pkg/commands/internal/testdata/plugin3/target/go.mod @@ -0,0 +1,3 @@ +module example.com/target + +go 1.24.0 \ No newline at end of file diff --git a/pkg/commands/internal/testdata/plugin3/target/target.go b/pkg/commands/internal/testdata/plugin3/target/target.go new file mode 100644 index 000000000000..ec6c15d2ee17 --- /dev/null +++ b/pkg/commands/internal/testdata/plugin3/target/target.go @@ -0,0 +1 @@ +package target