@@ -71,22 +71,150 @@ func (g *GoMod) SyncRequire(f io.Reader, throwerr bool) (gomod string, err error
7171 }
7272 }
7373 }
74- if len (g .ReplacedVersions ) != 0 {
75- data = append (data , "replace(" )
74+
75+ // Emit a replace block that pins every package in the source (host)
76+ // module graph to its exact source-selected version. Pinning via
77+ // `replace` — rather than relying on `require` alone — is required for
78+ // Go plugin ABI compatibility. Without it, the `go mod tidy` step that
79+ // callers run after this tool can upgrade transitive dependencies past
80+ // what the host binary is linked against (for example, a direct
81+ // dependency on github.com/99designs/gqlgen pulling in a newer
82+ // github.com/vektah/gqlparser/v2 than the host uses), causing
83+ // plugin.Open to fail at runtime with:
84+ // "plugin was built with a different version of package <pkg>".
85+ // Because replace directives override require resolution, this
86+ // guarantees the destination module is compiled against the exact same
87+ // versions as the source. Source-declared replaces take precedence over
88+ // pinning and are emitted verbatim.
89+ replacedMods := make (map [string ]bool , len (g .ReplacedVersions ))
90+ for _ , r := range g .ReplacedVersions {
91+ if len (r ) >= 1 {
92+ replacedMods [r [0 ].Name ] = true
93+ }
94+ }
95+
96+ // Compute the set of modules we will emit a replace directive for, so
97+ // that any pre-existing replace in the destination for the same module
98+ // can be stripped first. Leaving both in place would cause `go mod tidy`
99+ // to fail with "multiple replacements for <module>".
100+ emitModules := make (map [string ]bool , len (g .RequiredVersions )+ len (g .ReplacedVersions ))
101+ for _ , required := range g .RequiredVersions {
102+ if ! replacedMods [required .Name ] {
103+ emitModules [required .Name ] = true
104+ }
105+ }
106+ for _ , r := range g .ReplacedVersions {
107+ if len (r ) >= 1 {
108+ emitModules [r [0 ].Name ] = true
109+ }
110+ }
111+ data = stripConflictingReplaces (data , emitModules )
112+
113+ if len (g .RequiredVersions ) > 0 || len (g .ReplacedVersions ) > 0 {
114+ data = append (data , "replace (" )
115+ }
116+
117+ pinned := make (map [string ]bool , len (g .RequiredVersions ))
118+ for _ , required := range g .RequiredVersions {
119+ if pinned [required .Name ] || replacedMods [required .Name ] {
120+ continue
121+ }
122+ pinned [required .Name ] = true
123+ data = append (data , fmt .Sprintf ("\t %s => %s %s" , required .Name , required .Name , required .Version ))
76124 }
77125
78- //Add all the replaced versions from source to destination. Running go mod tidy after the utility will perform the cleanup in the destination go.mod and remove all the unwanted replace statements.
79- //Instead of trying to intelligently perform diffs, it is better to let the go mod tidy do the cleanup.
126+ // Add all the replaced versions from source to destination. Running go
127+ // mod tidy after the utility will perform the cleanup in the destination
128+ // go.mod and remove unused entries. Instead of trying to intelligently
129+ // perform diffs, it is better to let `go mod tidy` do the cleanup.
80130 for _ , replaced := range g .ReplacedVersions {
81- data = append (data , fmt . Sprintf ( " \t %s %s => %s %s" , replaced [ 0 ]. Name , replaced [ 0 ]. Version , replaced [ 1 ]. Name , replaced [ 1 ]. Version ))
131+ data = append (data , formatReplaceLine ( replaced ))
82132 }
83- if len (g .ReplacedVersions ) != 0 {
133+
134+ if len (g .RequiredVersions ) > 0 || len (g .ReplacedVersions ) > 0 {
84135 data = append (data , ")" )
85136 }
86137 gomod = strings .Join (data , "\n " )
87138 return
88139}
89140
141+ // stripConflictingReplaces removes any replace directive from the
142+ // destination go.mod lines whose "from" module is in the conflicts set.
143+ // Both forms are handled: single-line (`replace foo => bar v1`) and lines
144+ // inside an existing `replace (...)` block. Replaces for modules not in
145+ // conflicts are preserved — including destination-specific overrides that
146+ // the source does not touch. Empty replace blocks that may be left behind
147+ // are valid go.mod syntax; `go mod tidy` cleans them up.
148+ func stripConflictingReplaces (data []string , conflicts map [string ]bool ) []string {
149+ if len (conflicts ) == 0 {
150+ return data
151+ }
152+ out := make ([]string , 0 , len (data ))
153+ inReplaceBlock := false
154+ for _ , line := range data {
155+ trim := strings .TrimSpace (line )
156+
157+ if ! inReplaceBlock && (trim == "replace (" || trim == "replace(" ) {
158+ inReplaceBlock = true
159+ out = append (out , line )
160+ continue
161+ }
162+ if inReplaceBlock && trim == ")" {
163+ inReplaceBlock = false
164+ out = append (out , line )
165+ continue
166+ }
167+
168+ if strings .Contains (trim , "=>" ) {
169+ var rest string
170+ switch {
171+ case inReplaceBlock :
172+ rest = trim
173+ default :
174+ // go.mod allows arbitrary whitespace after the `replace`
175+ // keyword (e.g. `replace\tfoo => bar`), so tokenize rather
176+ // than match a fixed "replace " prefix. Skip any other line
177+ // containing "=>" (e.g. an in-comment arrow).
178+ fields := strings .Fields (trim )
179+ if len (fields ) == 0 || fields [0 ] != "replace" {
180+ out = append (out , line )
181+ continue
182+ }
183+ rest = strings .TrimSpace (strings .TrimPrefix (trim , fields [0 ]))
184+ }
185+ parts := strings .SplitN (rest , "=>" , 2 )
186+ from := strings .Fields (strings .TrimSpace (parts [0 ]))
187+ if len (from ) >= 1 && conflicts [from [0 ]] {
188+ continue
189+ }
190+ }
191+
192+ out = append (out , line )
193+ }
194+ return out
195+ }
196+
197+ // formatReplaceLine renders a single replace directive. The "from" version
198+ // is optional (e.g. `foo => ../foo`); the "to" version is absent for
199+ // local-path replacements.
200+ func formatReplaceLine (r []Package ) string {
201+ if len (r ) < 2 {
202+ return ""
203+ }
204+ from , to := r [0 ], r [1 ]
205+
206+ left := from .Name
207+ if from .Version != "" {
208+ left = from .Name + " " + from .Version
209+ }
210+
211+ right := to .Name
212+ if to .Version != "" {
213+ right = to .Name + " " + to .Version
214+ }
215+ return "\t " + left + " => " + right
216+ }
217+
90218// NewGoMod takes an io.Reader to a go.mod and returns GoMod struct
91219func New (f io.Reader ) (* GoMod , error ) {
92220 b , err := io .ReadAll (f )
@@ -130,6 +258,7 @@ func getRequiredVersionsFromString(s string) (p []Package) {
130258 return p
131259}
132260func getReplacedVersionsFromString (s string ) (p [][]Package ) {
261+ // Block form: replace ( ... )
133262 reps := ReplacePatternRegex .FindAllString (s , - 1 )
134263 for _ , req := range reps {
135264 data := getStringWithinCharacters (req , '(' , ')' )
@@ -146,6 +275,36 @@ func getReplacedVersionsFromString(s string) (p [][]Package) {
146275 }
147276 }
148277 }
278+
279+ // Single-line form: `replace foo [v] => bar [v]` outside any block.
280+ // go.mod allows arbitrary whitespace after the `replace` keyword, so
281+ // tokenize rather than match a fixed prefix.
282+ inBlock := false
283+ for _ , line := range strings .Split (s , "\n " ) {
284+ trim := strings .TrimSpace (line )
285+ if ! inBlock && (trim == "replace (" || trim == "replace(" ) {
286+ inBlock = true
287+ continue
288+ }
289+ if inBlock {
290+ if trim == ")" {
291+ inBlock = false
292+ }
293+ continue
294+ }
295+ if ! strings .Contains (trim , "=>" ) {
296+ continue
297+ }
298+ fields := strings .Fields (trim )
299+ if len (fields ) == 0 || fields [0 ] != "replace" {
300+ continue
301+ }
302+ rest := strings .TrimSpace (strings .TrimPrefix (trim , fields [0 ]))
303+ p0 := getPackagesAndVersionsFromPackageVersions (rest )
304+ if len (p0 ) != 0 {
305+ p = append (p , p0 )
306+ }
307+ }
149308 return p
150309}
151310func getPackagesAndVersionsFromPackageVersions (pkg string ) (p []Package ) {
0 commit comments