Skip to content

Write shared Go patch replaces into go.work - #1764

Merged
Mikola Lysenko (mikolalysenko) merged 47 commits into
mainfrom
feat/golang-workspace-writer
Oct 28, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 47 commits into
mainfrom
feat/golang-workspace-writer

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on the workspace-model PR.

Problem

No mode ever wrote a Socket replace into go.work. Agent and vendored replaces are directory paths relative to each member, so two members that use the same patched module always got different targets. Go refuses that with "conflicting replacements", so the second member was refused, and the docs told users to move the replace into go.work by hand. Patching a module shared by several workspace members therefore didn't work.

Change

  • When a go.work is in effect and the workspace build uses the patched module, the replace goes into go.work (go work edit -replace semantics):
    • local and vendored targets are relative to the go.work directory;
    • hosted pins are version-specific;
    • hosted checksum lines go into go.work.sum.
  • What was written is recorded, so remove, rollback and revert restore go.work and go.work.sum byte for byte, and --check, VEX and list discover it.
  • vendor taking over an apply redirect in a root go.mod moves it into go.work and drops the old line.
  • A go.work that doesn't use the project still gets the "not a member" refusal, with advice to run go work use ..
  • A symlinked go.work or go.mod is refused before anything is written, in every mode.
  • A missing final newline is preserved, so revert restores the file exactly.
  • Member and go.work pins of one uuid roll back together.
  • A hosted go.work pin warns when native vendor/ is stale.
  • The docs no longer tell users to edit go.work by hand. Rollback deletes a go.work.sum that removing the Socket lines leaves empty (documented).

Validation

All runs used real go1.26.3:

Suite Result
core lib 6845/0
e2e_golang_workspace_build 28/0
e2e_golang_build 27/0
e2e_golang_hosted_build 25/0
e2e_golang_hosted_state 29/0
e2e_golang 23/0
e2e_vendor_golang_build 38/0
hosted_memory_parity 42/0
hosted_memory_engine 49/0
in_process_rollback_hosted 41/0
e2e_vex_lockfile golang 23/0

New real-go tests cover:

  • a module shared by two members in all three modes, with go build -mod=readonly and go list -m all;
  • vendor moving a root go.mod redirect into go.work;
  • member and go.work pins rolling back together;
  • an exact round trip without a final newline.

tests/equivalence/golang_rewrite.golden was re-blessed for the newline change.

🤖 Generated with Claude Code


Note

Medium Risk
Changes how Go patch wiring is written and rolled back across workspace layouts, affecting apply, vendor, hosted redirect, VEX, and rollback; mistakes could leave members unpatched or break GOWORK=off builds.

Overview
Shared Go workspace patches now land in go.work (and hosted checksums in go.work.sum) when the workspace build uses the module, instead of duplicating per-member go.mod replaces that Go rejects as conflicting. Apply, vendor, and hosted scan use this path; a root module that is a use member still gets a mirror replace in root go.mod so GOWORK=off builds stay patched. Vendor can move an existing root go.mod redirect into go.work and repoint both files at the vendored copy.

Rollback / upstream restore is updated for go.work pins (including removing an empty go.work.sum), and VEX treats Socket’s own go.work replace as attestable rather than a workspace override. Discovery, hosted candidate files, consumer sync (go work vendor / stale vendor/), and symlink refusal on go.work/go.mod are wired through. CLI_CONTRACT.md and extensive e2e tests reflect the new contract.

Reviewed by Cursor Bugbot for commit 6249c81. Configure here.


Generated by Claude Code

When nothing imports a package from a hosted-patched Go module (it is
only a transitive requirement), `go mod tidy` keeps just the socket
module's `/go.mod` go.sum line and drops its zip line. The build still
succeeds offline, but VEX discovery required both lines, so the patch
was skipped as `package_not_found` instead of attested.

The `/go.mod` line for the exact socket module+version is now enough to
recognise the pin. Without the zip line the ref has no locked integrity
and is not integrity-required, so it attests from the lockfile: go
compiles none of the module's packages, and the module path carries
the patch uuid. A zip line, when present, must still be the single
well-formed hash and supplies `locked_integrity`. Conflicting hashes or
a malformed line for the socket module+version still mean no pin.

The hosted writer is unchanged: it still writes both lines and refuses
a module outside the build graph.

Assisted-by: Claude Code:claude-opus-5-5
A `/go.mod`-only go.sum line also exists for versions MVS reads but
does not select, so in a go < 1.17 graph a `/go.mod`-only hosted pin
whose version go.sum shows is not selected is now reported as inert
instead of attested.

In workspace mode go verifies against go.work.sum and every member's
go.sum, so discovery now reads those too (in-root members only) and a
member's zip line still supplies the locked integrity.

go.sum parsing now follows go: a line of other than three tokens makes
the whole file unusable, a non-dirhash `h1:` breaks that module's pin,
and hashes of other algorithms are ignored.

Re-running the hosted rewrite over a project `go mod tidy` pruned to
the `/go.mod` line now leaves go.sum alone instead of re-adding the zip
line the next tidy removes again.

Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-opus-5-5
In a go.work workspace, Go builds the highest version any `use` member
requires. A Socket replace whose go.sum held only the `/go.mod` line
was still attested when another member required a newer version, so
VEX could claim a patch that no build links. Such a pin now attests
only when every member's go.mod is inside the project and readable and
together they select the replaced version; otherwise it is flagged as
inert or left unattested.

Pins are also looked up only in the sum files Go actually reads: in a
workspace, go.work.sum and the members' go.sum (the root go.sum only
when `.` is a member); with GOWORK=off, the root go.sum alone, and the
go.work replaces are ignored as Go ignores them.

Assisted-by: Claude Code:claude-opus-5-5
`go mod tidy` drops the Socket module's zip checksum line while no
package of the patched module is imported. If the project later
imports it, the build fails with "missing go.sum entry", and
re-running `scan --mode hosted` left that state untouched, so the
project had no supported way back. A re-run now always writes both
go.sum lines, so it repairs the project and the build uses the patched
module again. The trade-off is that the next `go mod tidy` may drop
the line again while the module stays unimported.

Assisted-by: Claude Code:claude-opus-5-5
A go.work build never makes a member's go.mod list the versions it
builds, and it adds missing checksums to go.work.sum on its own. So a
member's dependency can pull in a newer version of a patched module
than any committed file names, and the build links the unpatched
module. vex used to attest such a pin from the lockfile whenever the
members' go.mod files named the patched version.

In a workspace a pin with only the /go.mod line now needs a verifying
installed copy, and a newer version in a member's require or in any
recorded zip line marks the replace as inert. Docs and the CLI contract
say so, and a real-go test covers the member dependency case.

Assisted-by: Claude Code:claude-opus-5-5
go only ever appends to go.work.sum: once a member stops depending on
whatever raised a module, the zip line of the higher version stays
behind. vex treated such a line as proof the workspace builds that
version, so it dropped a Go /go.mod-only pin as inert even when go
builds the patched version.

Only a member's require or the zip lines of a member's go.sum now mark
a workspace pin inert. The pin still needs a verifying installed copy
to attest.

Assisted-by: Claude Code:claude-opus-5-5
A workspace's sum files are only appended to, so the socket zip line a
past build wrote to go.work.sum (or a member go.sum) stays after a member
starts requiring a higher version of the replaced module. Go then builds
that higher version and the replace is inert, but discovery attested the
zip-pinned ref from the lockfile anyway, and the CLI's post-gate reads
only the root go.mod and go.sum, so vex claimed a patch the build never
links. Run the same inert check GoModOnly pins already get on Zip pins
in workspace mode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each Go command checked go.work its own way, and some of those checks
disagreed with go. A replace pinned to v1.0.0 counted as patched even
when another workspace member required v1.1.0, so go built the newer,
unpatched version while apply, vendor and vex treated it as fixed. A
go.work replace of a different version was taken to override a
member's replace, though go still uses the member's. GOWORK=auto was
treated as a file name, and a relative GOWORK, which go rejects, was
read as if it were in effect.

Every Go path now asks one workspace model. It finds go.work as go
does, picks the version the whole workspace builds from all members'
requirements, and applies go's replace precedence. A member go.mod
that can't be read leaves the answer unknown, so nothing is attested
on a guess. VEX now also reads member go.mod files from a go.work root
that has no go.mod of its own, and apply and vendor refuse there and
name the members to run them in.

Assisted-by: Claude Code:claude-opus-5-5
`scan --mode hosted` wrote a Go replace without looking at the go.work
in effect, and only warned afterwards. A replace that conflicted with
another member's broke every build in the workspace, and one that
go.work overrode was reported as redirected while go built something
else.

Hosted mode now checks the workspace before writing. A replace go.work
would override, ignore or reject is not written, the run warns
`redirect_golang_workspace_conflict`, and the patch is not reported
as redirected. The version check uses the whole workspace too, so a
member that doesn't require the module can still carry the replace
when another member does, and one that raises it to a newer version
refuses the pin. From a go.work root with no go.mod of its own, the
pin goes into each member that lists the module.

Assisted-by: Claude Code:claude-opus-5-5
`rollback` only looked at the root go.mod and go.sum. A hosted pin
in go.work was refused with a "restore it from version control"
error, the Socket module's lines that go writes to go.work.sum in
workspace mode were left behind, and a pin in a workspace member's
go.mod was not found from the workspace root.

The restore now edits the go.mod or go.work each pin was found in,
removes the Socket module's lines from that file's go.sum,
go.work.sum and, for a go.work pin, the members' go.sum, and puts
the upstream module's lines back where the Socket ones were.

Assisted-by: Claude Code:claude-opus-5-5
Real-go tests now cover a go.work root without a go.mod (hosted pins
in each member, attested from the root, rolled back byte for byte),
a member raising the patched module's version, GOWORK unset, `auto`,
`off` and relative, the hosted go.work gate under go's replace
precedence, and rollback of a go.work pin and its go.work.sum lines.
Each test fails before this branch's fixes.

The CLI contract and the Go ecosystem docs now describe how the
workspace is found, how its version and replace precedence are
read, and what each command does from a go.work root.

Assisted-by: Claude Code:claude-opus-5-5
In a go.work workspace go keeps no member's go.mod tidy, so a member's
dependency can raise a patched module above the version the Socket
replace is pinned to; the only trace is the zip line go writes to
go.work.sum or a member's go.sum. A higher zip line now proves the pin
inert even when a member's go.mod is out of reach, for vex and for
apply, vendor and hosted writes.

Another member replacing every version of the module (replace M =>
...) now blocks apply, vendor and hosted writes and makes vex leave the
patch out: go links that target or refuses to build.

At a go.work root, a hosted pin lands in every member that lists the
module or in none, instead of staying half-written when one member
refuses.

When GOWORK names a file other than the project's go.work, or a
relative path go rejects, vex attests no Go patch and reports each one.

Assisted-by: Claude Code:claude-opus-5-5
In a go.work workspace three things made socket-patch report a Go patch
as applied, or refuse one, when go did otherwise:

- A module that is itself a `use` member (a local checkout) is built
  from its directory, and go ignores every replace of it. apply, vex and
  hosted mode still wrote and attested the replace.
- go only ever appends to go.work.sum, so a zip line of a version that
  nothing requires any more stayed behind and made apply and hosted mode
  refuse a pin that go builds.
- A member's dependency can raise the module past every member require.
  Before a build wrote go.work.sum nothing showed it, so a fresh clone
  attested a pin that go ignores.

The selection is now worked out the way go does it: minimal version
selection over each member's requires and the go.mod of every
dependency go loads, read from its replacement directory or the module
cache. Sum files are no longer evidence. When a needed go.mod is missing
the answer is unknown: apply --check fails, vex leaves the patch out
(go_version_not_selected) and hosted mode writes nothing, until
`go mod download` fills the cache. A replace of a workspace module is
refused as a workspace conflict.

Assisted-by: Claude Code:claude-opus-5-5
The repo guard forbids private --json envelope readers in test files,
so the workspace e2e reads warning codes through the shared helper.

Assisted-by: Claude Code:claude-opus-5-5
With the workspace model, the go.work-level check reports a raised
zip-pinned replace as inert (the workspace requires the higher
version), so the test asserts the verdict and version, not the
module-mode sentence.

Assisted-by: Claude Code:claude-opus-5-5
Run in the directory holding go.work, apply, vendor and hosted scans
now replace a module the workspace shares once in go.work, the way
`go work edit -replace` does: when that directory has no go.mod of
its own, when another member lists the module, or when go.work already
holds our replace of it. Before, member replaces of one patched copy
resolved to different directories and go refused the workspace, so
the second member was refused and the docs told users to move the
replace by hand; a go.work root without a go.mod could not be patched
by apply or vendor at all.

The vendored copy stays at the root and the go.work target is relative
to it. A hosted pin is version-specific and its sum lines go into
go.work.sum, leaving the members' go.mod and go.sum untouched. A
go.work replace is refused when go.work already replaces that version
with something else, or a member's own replace would be overridden.

The vendored ledger records go.work as the wiring file, and the
project's replaces are read from both files, so --check, remove,
vendor --revert, list and vex find and restore them there. Rollback
of a go.work pin no longer adds upstream sum lines that were never
removed, and deletes a go.work.sum the pin alone created, so go.work
and go.work.sum come back byte for byte.

Assisted-by: Claude Code:claude-opus-5-5
Describe when each Go mode writes its replace into go.work, how the
target and go.work.sum pin look, what a GOWORK=off build sees, and how
check, revert, vex and rollback treat it. Drop the advice to move the
replace into go.work by hand.

Assisted-by: Claude Code:claude-opus-5-5
Real-go e2e from a go.work root with no go.mod, two members requiring
the same module: vendor, apply and hosted each write one replace, both
members build the patched module with -mod=readonly and go list -m all
shows the replacement, a re-run changes nothing, and vendor --revert
and rollback give go.work back byte for byte.

Assisted-by: Claude Code:claude-opus-5-5
A hosted scan that pins a module in go.work now checks a committed
workspace vendor/modules.txt too, and names `go work vendor` when it
does not record the new replace, as it already did for go.mod pins.

Assisted-by: Claude Code:claude-opus-5-5
A replace that apply wrote into the root go.mod, before another
workspace member required the module, now moves into go.work the next
time apply or vendor writes it. Before, vendor added the go.work line,
left the go.mod line behind and then deleted the copy it named, so a
GOWORK=off build of the root failed and vendor --revert kept the
dangling line. The ledger now records that original against go.mod.

Rolling back hosted pins of one patch held in several members' go.mod
and in go.work now puts the upstream go.sum lines back in every
member's go.sum and deletes the go.work.sum the pin created. Before,
only the first member got its lines back, so the others failed
-mod=readonly builds, and go.work.sum was left holding upstream lines.

apply, vendor and hosted mode again refuse a project whose go.mod the
go.work beside it does not use, instead of writing a go.work replace
that patches the other members but never the project itself.

Assisted-by: Claude Code:claude-opus-5-5
Run at a go.work root without a go.mod of its own, apply, vendor and
hosted mode write a shared replace into go.work. When the replaced
module is itself a `use` member (a local checkout), go builds it from
that directory and ignores the replace, so the patch was reported as
applied while go linked the local, unpatched copy.

The go.work check now refuses that replace, as the member go.mod check
already does. The e2e test runs hosted mode from the root as well, and
the shared-module re-run test fills the module cache first, since a
workspace's selection is read from it.

Assisted-by: Claude Code:claude-opus-5-5
apply writes a shared replace into go.work, and apply, remove and
rollback rewrite go.mod, with an atomic rename. When either file was a
symbolic link the rename replaced the link with a regular file, so the
user's link was gone for good, while vendor and hosted mode refused the
same layout and wrote nothing.

apply now refuses before it copies anything when the file that would
hold the replace is a link, and every go.mod or go.work edit refuses
to change a symlinked file, so remove and rollback leave it alone too.

Assisted-by: Claude Code:claude-opus-5-5
A replace appended to a go.mod or go.work that had no final newline
ended with one, and remove, vendor --revert and rollback had no way to
tell it was added. The file then kept an extra newline after a full
revert, so the tree showed a diff the docs say it won't.

The appended replace now ends without a newline when the file did, and
removing it restores the file byte for byte. The hosted rewriter's
golden digests change for the seeded go.mod files without one.

Assisted-by: Claude Code:claude-opus-5-5
The docs said rollback deletes a go.work.sum "the pin alone created".
Hosted mode keeps no record of which files existed before a pin, so it
deletes any go.work.sum that stripping the Socket lines leaves empty,
including one a user created empty. go never writes an empty
go.work.sum, so that only happens to a file made empty by hand. Say
so, rather than claim rollback can tell the cases apart.

Assisted-by: Claude Code:claude-opus-5-5
vex reads which version a go.work workspace selects from its module
graph in the module cache, and since its go.work replaces are checked
from the workspace root, the go.work flavor of the lockfile fixtures
left them out with go_version_not_selected. The fixture now puts the
patched module's go.mod in its private cache, as `go mod download`
would.

Assisted-by: Claude Code:claude-opus-5-5
When the replace host is go.work and the root module mirrors it in
go.mod, apply's in-sync fast path and `apply --check` accepted any
matching Socket replace, so the go.work line alone counted. A deleted
go.mod mirror then made apply a no-op and `--check` pass while a
GOWORK=off build linked the unpatched module (Bugbot on
golang_local.rs:910).

Both now require the replace in every file apply writes it into (the
host plus the mirrored root go.mod), via superseded_replace.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Main landed lighter fixes for the same go.work gaps this PR models in
full: #1752 (version gate reads every member), #1753 (hosted pins
outranked by another member), #1767 (stale go.work vendor warning) and
the squashed #1748. Resolve them onto this PR's GoWorkspace model
instead of keeping two workspace readers:

- go_build_list: keep project_selection on GoWorkspace. Drop main's
  ModuleEvidence / workspace_selection / other_workspace_members and
  its three on-disk selection tests: GoWorkspace::selection covers
  those cases from the module graph (go_workspace tests), and without
  a module cache it answers Unknown by design.
- hosted: drop #1753's go_members plumbing (engine RewriteOptions
  closure, rewrite_golang argument, CLI wiring). The PR's
  HostedGoWorkspace already refuses a pin another member outranks.
  Main's e2e test for it now seeds the module cache the workspace
  graph reads, and passes, including the rescan that removes the pin.
- go_consumer_sync: port #1767's modules.txt audit (per-member replace
  resolution, member_owned, unreadable members skipped) onto
  go_workspace::load and its shared same_dir_key / lexical_target.
- vex discover, CLI_CONTRACT, e2e_golang_hosted_build: keep the PR's
  stricter rules (a zip pin is checked against the workspace selection
  too; sum-file zip lines are no evidence of it).

Co-Authored-By: Claude <noreply@anthropic.com>
673e246 made the in-sync check also require the root go.mod mirror of
a go.work replace. That was too strict and too costly: a replace held
only in the root go.mod still patches the workspace (the e2e positive
control in go_work_without_the_project_ignores_its_replace went red),
and a missing mirror sent apply down the rebuild path, whose failure
teardown drops the go.work replace the workspace builds through
(Bugbot on golang_local.rs:713).

Now only the mirror itself is checked: `--check` reports it missing,
and apply's hot path writes it back with ensure_replace_entry, leaving
the copy and the go.work replace alone.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Brings in the base's main merge (678f90e). It replaced
go_consumer_sync's same_replacement with lexical_target comparison
(main #1767), so the go.work modules.txt check here now compares the
go.work replace and the recorded line the same way, both resolved
against the go.work directory.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 6249c81. Configure here.

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 28, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Ready for review at 6249c81 (burn-down agent).


Generated by Claude Code

Base automatically changed from fix/golang-workspace-model to main October 28, 2026 20:19
@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 28, 2026
Main squash-merged #1763 (the workspace-model branch this PR had been
merging in), so every model file conflicted. No main commit since the
squash touches these files; the only model commit this branch lacked was
ed7d030 (keep a workspace pin of a dependency-raised version live), which
this merge brings in via vex/discover/golang.rs. Resolved with the last
model tip this branch merged (678f90e) as the base, keeping the writer's
go.work changes intact.

Co-Authored-By: Claude <noreply@anthropic.com>
Merged via the queue into main with commit eb75d20 Oct 28, 2026
49 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the feat/golang-workspace-writer branch October 28, 2026 21:56
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Oct 28, 2026
#1764, which this PR stacked on, squash-merged into main as eb75d20, so
every conflict was between this branch's older copy of that stack and
the landed version. Take main's side for all of them and keep this PR's
only unique change: the two go.work tests in
e2e_golang_workspace_build.rs (go work vendor, and which file a run
location writes the replace to). The full e2e_golang_workspace_build
target passes locally (30/30).

Co-Authored-By: Claude <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Oct 28, 2026
Resolve conflicts with main's go.work replace hosting (#1763, #1764,
#1765). ensure_replace_entry now validates and backs up go.sum before
writing the replace into main's replace host (go.work or go.mod), and
drop_replace_entry restores the saved go.sum lines before removing the
replace from go.work and go.mod. The cleanup fallback uses the same
go.work-aware removal now that edit_go_mod is gone. The workspace e2e
test keeps this branch's reinstall steps on main's tail(&Tail{..}) shape.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants