Skip to content

Read Go workspaces the way go builds them - #1763

Merged
Mikola Lysenko (mikolalysenko) merged 24 commits into
mainfrom
fix/golang-workspace-model
Oct 28, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 24 commits into
mainfrom
fix/golang-workspace-model

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

Stacked on #1748.

Problem

socket-patch read Go workspaces only in pieces, and several paths disagreed with what go1.26 actually builds in a go.work workspace:

  • "Is M@v selected?" read only the project's own go.mod. Another use member, or a dependency of one, can raise M above the patched version, so the version-pinned Socket replace does nothing. Apply, vendor, hosted and VEX still reported it as patched.
  • Hosted mode wrote the go.mod replace without checking the workspace. A replace that conflicts with another member's breaks every workspace build. One that go.work overrides was still confirmed.
  • Hosted refused with redirect_golang_not_in_module_graph when the member didn't require M, although a member's replace applies to the whole workspace build.
  • A workspace root with go.work and no go.mod (the most common layout) was skipped.
  • Rollback ignored pins in go.work and never cleaned go.work.sum.
  • GOWORK=auto was treated as a path. A relative GOWORK was resolved against the project, but go rejects it.
  • The replace precedence model was wrong. go 1.26 checks the go.work exact-version replace, then the go.work wildcard, then every member's replaces. A member replace of a different version still applies.
  • A use member that is the patched module (a local checkout) is built from source. Go ignores every replace of it, yet it was reported as patched.

Change

  • New vendor/go_workspace.rs: one GoWorkspace model, used by apply, vendor, hosted, VEX, --check and list. It covers:
    • GOWORK resolution (unset/auto searches upward, off, absolute path, relative path refused);
    • member loading;
    • Go's replace precedence and conflict rules;
    • workspace-wide selection: MVS over every member's requirements and each dependency's go.mod, read from its replacement directory or the module cache. Pre-1.17 go.mod files pull in their whole graph, as in Go's readModGraph. A version above the patched one proves the pin inert. A missing go.mod makes the answer Unknown.
  • Selection no longer trusts sum files. go.work.sum is never pruned, so a stale zip line is not evidence.
  • On an Unknown selection in a workspace:
    • apply --check fails with Drift::UnprovenSelection;
    • the VEX gate withholds with go_version_not_selected;
    • hosted writes nothing;
    • apply still writes the replace.
  • Hosted consults the workspace before writing. Conflicting or overridden pins are dropped with a redirect_golang_workspace_conflict warning, and their uuid is not confirmed.
  • Replacing a module that is itself a workspace member is refused (WorkspaceConflict::MainModule).
  • go.work roots without go.mod work in every mode, operating over the use members.
  • Rollback restores go.work pins and go.work.sum lines.
  • VEX discovery resolves GOWORK the same way.
  • docs/ecosystems.md and CLI_CONTRACT.md are updated.

Behaviour change to review

In a workspace whose module graph can't be read (a fresh clone before go mod download), VEX and apply --check now withhold and hosted writes nothing, instead of assuming the patched version is selected. The in-memory hosted engine has no module cache, so it now writes no Go workspace pins. This fails closed, which matches how depscan's SBOM settles workspaces (SocketDev/depscan#29121). It does mean hosted autopatch PRs skip Go workspaces until a graph source is available there.

Validation

All runs used real go1.26.3 with SOCKET_PATCH_GO_E2E_REQUIRED=1:

Suite Result
core lib 6839/0
e2e_golang_workspace_build 23/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

cargo fmt --check is clean. Two independent review passes checked each fix against real-go fixtures served from a local module proxy:

  • a Go-semantics pass, hunting for false attestation and false refusal;
  • a state-safety pass, covering atomicity, rollback and mode switches.

New real-go e2e cases:

  • a dependency raise makes a workspace pin inert;
  • a stale go.work.sum line does not refuse a pin;
  • a workspace module is never replaced;
  • hosted respects go.work precedence;
  • go.work root without go.mod;
  • rollback of go.work and go.work.sum pins.

🤖 Generated with Claude Code


Note

Medium Risk
Changes how patched Go deps are detected, redirected, and attested in monorepos; incorrect logic could miss or falsely claim patches, but the design fails closed when the module graph is unknown.

Overview
Go workspaces are modeled in one place (vendor/go_workspace.rs) and wired through apply, vendor, hosted redirect, VEX, rollback, and discovery so behavior matches how go1.26 resolves GOWORK, use members, replace precedence, and MVS over the full module graph (including dependency go.mod files from replacement dirs or GOMODCACHE).

Selection and attestation fail closed in workspaces: when the graph cannot be proven (e.g. fresh clone before go mod download), hosted skips pins, VEX and apply --check report go_version_not_selected / UnprovenSelection, and transitive version raises mark pins inert instead of attesting stale replaces. Hosted also refuses conflicting or overridden replaces (redirect_golang_workspace_conflict), supports go.work-only roots (pins per member), restores go.work / go.work.sum on rollback, and stops treating go.work.sum zip lines as selection evidence.

The in-memory hosted engine no longer writes Go workspace pins without a module cache. CLI_CONTRACT.md, ecosystems docs, and broad real-go e2e coverage document and exercise the new rules.

Reviewed by Cursor Bugbot for commit 678f90e. 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

@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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: VEX skips go.work selection withhold
    • Added logic to load workspace and check go.work replaces for selection, applying the same withholding when the module graph is unavailable.

Create PR

Or push these changes by commenting:

@cursor push f1577c1e3e
Preview (f1577c1e3e)
diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs
--- a/crates/socket-patch-cli/src/commands/vex.rs
+++ b/crates/socket-patch-cli/src/commands/vex.rs
@@ -998,6 +998,16 @@
                 entries.push((root.clone(), entry));
             }
         }
+        // Also check go.work replaces: discovery emits them when selection is
+        // Unknown, but the gate must not attest an unproven workspace selection.
+        if let socket_patch_core::vendor::go_workspace::Loaded::Workspace(workspace) =
+            socket_patch_core::vendor::go_workspace::load(&common.cwd).await
+        {
+            let work_root = workspace.work_dir().to_path_buf();
+            for entry in workspace.replaces() {
+                entries.push((work_root.clone(), entry));
+            }
+        }
         for (root, entry) in entries {
             let Some(version) = entry.version.as_deref().filter(|_| entry.socket_owned()) else {
                 continue;

diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs
--- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs
+++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs
@@ -550,13 +550,17 @@
 // ══════════════════════════════════════════════════════════════════════
 
 /// a + g(not installed): go.mod (line / block) and go.work flavors attest
-/// `(redirected)` for the ORIGINAL module's purl.
+/// `(redirected)` for the ORIGINAL module's purl. go.work requires the
+/// module graph to be cached (workspace selection check).
 #[test]
 fn golang_hosted_a_attests_every_flavor_without_manifest_or_ledgers() {
     let api = Api::start(vec![(U, go_view(U))]);
     for file in GO_FILES {
         let fx = Fx::new();
         write_go_hosted(&fx, U, file);
+        if file == GoFile::GoWork {
+            fx.cache_go_mod(GO_MODULE, GO_VERSION);
+        }
         let run = fx.vex(&["--proxy-url", &api.uri()]);
         assert_attested(&fx, &run, GO_PURL, U, "redirected", &format!("{file:?}"));
         assert!(
@@ -1046,9 +1050,8 @@
     assert_attested(&fx, &run, GO_PURL, U, "redirected", "graph cached");
 }
 
-/// A hand-written go.work replace whose pin lines stayed in the root
-/// go.sum (go reads both sum files in workspace mode): still pinned, still
-/// attested; the vendored twin needs no pin.
+/// A hand-written go.work replace with proper pins: withheld until the
+/// module graph is available (like go.mod replaces), then attested.
 #[test]
 fn golang_go_work_replace_pinned_by_root_go_sum_attests() {
     let api = Api::start(vec![(U, go_view(U))]);
@@ -1058,7 +1061,10 @@
     fx.rm("go.work.sum");
     fx.put("go.sum", sums);
     let run = fx.vex(&["--proxy-url", &api.uri()]);
-    assert_attested(&fx, &run, GO_PURL, U, "redirected", "go.work + go.sum pin");
+    assert_omitted(&run, GO_PURL, "go_version_not_selected", "empty cache");
+    fx.cache_go_mod(GO_MODULE, GO_VERSION);
+    let run = fx.vex(&["--proxy-url", &api.uri()]);
+    assert_attested(&fx, &run, GO_PURL, U, "redirected", "graph cached");
 }
 
 /// LIMITATION (documented, root-only discovery): a Socket replace in a

You can send follow-ups to the cloud agent here.

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
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: backing off this stack — another session pushed fixes to #1763/#1764/#1765 at 04:20–04:21Z while I was on it (its envelope fix in be8131f is identical to mine, so nothing is lost).

The open Bugbot finding on vex.rs:1006 (go.work pins skip the selection gate) is real: the gate only reads Socket replaces from each module root's go.mod, so a replace in go.work itself is attested on a fresh clone with no module graph cached, which contradicts golang_workspace_without_its_module_graph_is_not_attested. Proposed fix, not pushed (applies cleanly on be8131f; e2e_vex_lockfile 45/45 and e2e_vex 354/354 pass locally with it):

patch
diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs
index ca01bfd..c736409 100644
--- a/crates/socket-patch-cli/src/commands/vex.rs
+++ b/crates/socket-patch-cli/src/commands/vex.rs
@@ -998,6 +998,15 @@ async fn generate_vex(
                 entries.push((root.clone(), entry));
             }
         }
+        // A go.work replace pins the whole workspace, and discovery attests
+        // it like a go.mod one, so it passes the same selection gate.
+        if let socket_patch_core::vendor::go_workspace::Loaded::Workspace(ws) =
+            socket_patch_core::vendor::go_workspace::load(&common.cwd).await
+        {
+            for entry in ws.replaces() {
+                entries.push((common.cwd.clone(), entry));
+            }
+        }
         for (root, entry) in entries {
             let Some(version) = entry.version.as_deref().filter(|_| entry.socket_owned()) else {
                 continue;
diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs
index b46685e..0700b3a 100644
--- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs
+++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs
@@ -504,6 +504,9 @@ fn write_go_replace(fx: &Fx, file: GoFile, rhs: &str, sum: Option<&str>) {
             if let Some(sum) = sum {
                 fx.put("go.work.sum", sum);
             }
+            // A workspace's selection is read from its module graph, which
+            // `go mod download` puts in the module cache.
+            fx.cache_go_mod(GO_MODULE, GO_VERSION);
         }
     }
 }
@@ -1046,6 +1049,26 @@ fn golang_workspace_without_its_module_graph_is_not_attested() {
     assert_attested(&fx, &run, GO_PURL, U, "redirected", "graph cached");
 }
 
+/// The same gate for a replace in go.work itself: it pins the whole
+/// workspace, so an unread module graph leaves it out too.
+#[test]
+fn golang_go_work_pin_without_its_module_graph_is_not_attested() {
+    let api = Api::start(vec[(U, go_view(U))]);
+    let fx = Fx::new();
+    let rhs = format!("{} {GO_SVER}", go_socket_module(U));
+    fx.put("go.mod", go_mod(GO_VERSION, None, false));
+    fx.put(
+        "go.work",
+        format!("go 1.21\n\nuse .\n\nreplace {GO_MODULE} {GO_VERSION} => {rhs}\n"),
+    );
+    fx.put("go.work.sum", go_sum_lines(U));
+    let run = fx.vex(&["--proxy-url", &api.uri()]);
+    assert_omitted(&run, GO_PURL, "go_version_not_selected", "empty cache");
+    fx.cache_go_mod(GO_MODULE, GO_VERSION);
+    let run = fx.vex(&["--proxy-url", &api.uri()]);
+    assert_attested(&fx, &run, GO_PURL, U, "redirected", "graph cached");
+}
+
 /// A hand-written go.work replace whose pin lines stayed in the root
 /// go.sum (go reads both sum files in workspace mode): still pinned, still
 /// attested; the vendored twin needs no pin.

The fixture change is needed because two existing go.work tests (golang_go_work_replace_pinned_by_root_go_sum_attests, golang_hosted_a_attests_every_flavor_without_manifest_or_ledgers) relied on attesting with an empty module cache.


Generated by Claude Code

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
The VEX selection gate only read Socket replaces from each module
root's go.mod, so a replace written into go.work itself skipped it and
was attested on a fresh clone with no module graph cached, contradicting
golang_workspace_without_its_module_graph_is_not_attested. Feed the
workspace's own replaces through the same gate (Bugbot, vex.rs:1006).

The go.work fixture now caches the module graph, as `go mod download`
would, since two existing go.work tests relied on attesting with an
empty module cache.

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.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Workspace members loaded from cache
    • Added filters to skip workspace members when building the module graph, ensuring they are loaded from local directories instead of the cache.

Create PR

Or push these changes by commenting:

@cursor push 69c456376a
Preview (69c456376a)
diff --git a/crates/socket-patch-core/src/vendor/go_workspace.rs b/crates/socket-patch-core/src/vendor/go_workspace.rs
--- a/crates/socket-patch-core/src/vendor/go_workspace.rs
+++ b/crates/socket-patch-core/src/vendor/go_workspace.rs
@@ -395,6 +395,7 @@
             .iter()
             .filter_map(|m| m.go_mod.as_deref())
             .flat_map(go_mod_edit::parse_required_versions)
+            .filter(|(path, _)| self.main_module(path).is_none())
             .map(|(path, v)| (path, v, false))
             .collect();
         while let Some((path, version, unpruned)) = queue.pop_front() {
@@ -411,7 +412,9 @@
             };
             if unpruned || !dep.pruned {
                 for (p, v) in &dep.requires {
-                    queue.push_back((p.clone(), v.clone(), true));
+                    if self.main_module(p).is_none() {
+                        queue.push_back((p.clone(), v.clone(), true));
+                    }
                 }
             }
         }

You can send follow-ups to the cloud agent here.

read_workspace dropped every main module when any `use` member was
outside the root or unreadable, so a readable member requiring a higher
version no longer marked a workspace zip pin inert and vex attested a
replace go does not build (Bugbot, golang.rs:271). In a workspace the
members' requires only ever prove a replace inert, so the readable ones
still count when another member cannot be read.

Co-Authored-By: Claude <noreply@anthropic.com>
…into fix/golang-workspace-model-bd

# Conflicts:
#	crates/socket-patch-core/src/vex/discover/golang.rs
@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.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Hosted confirms unproven workspace pins
    • Modified the condition to always refuse writing/confirming redirects when workspace selection is Unknown, regardless of whether a same-version prior replace exists.

Create PR

Or push these changes by commenting:

@cursor push f99356fa7e
Preview (f99356fa7e)
diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs
--- a/crates/socket-patch-core/src/patch/redirect/mod.rs
+++ b/crates/socket-patch-core/src/patch/redirect/mod.rs
@@ -9575,7 +9575,8 @@
         }
         return false;
     } else if selection != go_build_list::Selection::Selected
-        && prior.is_none_or(|e| e.version.as_deref() != Some(dep.version.as_str()))
+        && (selection == go_build_list::Selection::Unknown
+            || prior.is_none_or(|e| e.version.as_deref() != Some(dep.version.as_str())))
     {
         // Not selected by go.mod's require, no go.sum zip line at this
         // version, and not already redirected by us: the module is

You can send follow-ups to the cloud agent here.

read_graph_from queued every member requirement and every raised
version as pruned, so it could miss a higher version of the patched
module that go loads and report the pin as Selected. Go enqueues each
main module at its workspace level, which loads the member's
requirements at the member's own pruning: a go 1.16 member loads its
whole transitive graph. It enqueues raised versions at the workspace
level too, so their direct requirements always load. Mirror that, and
dedup as go's loading/loadingUnpruned maps do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko Mikola Lysenko (mikolalysenko) removed 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

bugbot run


Generated by Claude Code

@cursor

cursor Bot commented Oct 28, 2026

Copy link
Copy Markdown

Bugbot is paused — your team has an unpaid invoice

Bugbot is paused because your team has an unpaid invoice.

Pay it from the billing page in the Cursor dashboard and Bugbot resumes automatically.

(requestId: 26dd0e79-1e43-4960-9e22-7359b2c44f39)

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

1 similar comment
@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.

@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 5a2720f (burn-down agent).

  • CI: 48/48 check runs green or skipped (29 success, 19 skipped).
  • Bugbot reviewed 5a2720f: no issues found. No unresolved review threads.
  • Stacked on Keep Go hosted pins after go mod tidy drops the zip line #1748 (base fix/golang-tidy-gomod-only-pin, which carries agent:needs-human), so it merges after that one.
  • Slack announcement not sent this run (Slack posting unavailable); the next run retries.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Ready for review at 5a2720f (burn-down agent).

  • CI: 48/48 check runs on 5a2720f green or skipped (29 success, 19 skipped); mergeable into fix/golang-tidy-gomod-only-pin (stacked on Keep Go hosted pins after go mod tidy drops the zip line #1748, which has agent:needs-human).
  • Bugbot reviewed 5a2720f: no issues found. Billing is restored, so Bugbot is running again.
  • All review threads are resolved, including the final reviewer's pruning-levels fix in 5a2720f.
  • Slack announcement not sent: the Slack send tool was unavailable this run.

Generated by Claude Code

Base automatically changed from fix/golang-tidy-gomod-only-pin to main October 28, 2026 18:02
@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 28, 2026
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>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: merged main into this branch at 678f90e to clear the conflict. I removed the Ready for review label; it goes back on once CI and Bugbot are green on the new head.

Main had landed lighter fixes for the same go.work gaps (#1752, #1753, #1767, and #1748 squashed). I resolved them onto this PR's GoWorkspace model so there is one workspace reader, not two:

  • Hosted (Hosted Go scan in a go.work member pins a version another member outranks #1753): I removed main's go_members plumbing (the RewriteOptions::go_workspace closure, the extra rewrite_golang argument and the CLI wiring). HostedGoWorkspace already refuses a pin that another member outranks. Main's e2e test hosted_pin_another_go_work_member_outranks_is_refused now seeds the module cache that the workspace graph reads, and it passes, including the rescan that removes the inert pin.
  • Selection (Fix Go version gate ignoring go.work members (#1751) #1752): I dropped ModuleEvidence, workspace_selection, other_workspace_members and main's three on-disk project_selection workspace tests. With no module cache they now get Unknown, which is this PR's intended answer, and go_workspace's tests cover the same cases with a cache. A reviewer should confirm that dropping these tests is acceptable.
  • modules.txt audit (Fix stale go.work vendor warning for members (#1766) #1767): I ported it onto go_workspace::load, keeping main's per-member replace resolution and member_owned, and still skipping unreadable members.
  • VEX discovery, CLI_CONTRACT.md and e2e_golang_hosted_build: these keep the PR's stricter rules.

Local results: Go-related core unit tests pass (1034). The Go CLI suites pass: e2e_golang_build, e2e_golang_hosted_build, e2e_golang_hosted_state, e2e_golang_workspace_build, e2e_vendor_golang_build and e2e_vex_lockfile. The full core suite has 4 failures, all permission tests in files identical to main; they fail because this container runs as root.

Heads-up: #1764 (and #1765 on top of it) is stacked on this branch and will need this merge brought in.


Generated by Claude Code

@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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Raised-version pin treated as inert
    • Modified replaced_purl to only treat lower-version pins as definitively inert, allowing MVS-raised replace pins to be validated by graph.select().

Create PR

Or push these changes by commenting:

@cursor push 22c510b70b
Preview (22c510b70b)
diff --git a/crates/socket-patch-core/src/vex/discover/golang.rs b/crates/socket-patch-core/src/vex/discover/golang.rs
--- a/crates/socket-patch-core/src/vex/discover/golang.rs
+++ b/crates/socket-patch-core/src/vex/discover/golang.rs
@@ -585,15 +585,21 @@
     };
     let version = match (entry.version.as_deref(), req) {
         (Some(v), Some(r)) if v != r => {
-            out.diag(
-                DIAG_REF_INVALID,
-                file,
-                format!(
-                    "{file}: replace {module} {v} is inert: {basis} {module} {r}, \
-                     so the build uses the unpatched module"
-                ),
-            );
-            return None;
+            // In a workspace, MVS can select a version higher than any
+            // member's direct require. Only treat lower pins as definitively
+            // inert; let the caller's graph.select() check handle higher ones.
+            if go_semver_cmp(v, r).is_lt() {
+                out.diag(
+                    DIAG_REF_INVALID,
+                    file,
+                    format!(
+                        "{file}: replace {module} {v} is inert: {basis} {module} {r}, \
+                         so the build uses the unpatched module"
+                    ),
+                );
+                return None;
+            }
+            v
         }
         (Some(v), _) => v,
         (None, Some(r)) if versioned.contains(&(module, r)) => {

You can send follow-ups to the cloud agent here.

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

Reviewed by Cursor Bugbot for commit 678f90e. Configure here.

In a workspace go tidies no member, so a dependency can raise a module
above every member's `require`. `replaced_purl` compared a versioned
replace only against the highest member `require`, so a pin of the
version go actually builds was diagnosed inert and dropped from
discovery, attribution and VEX, even though hosted writes exactly that
pin. Accept the pin when the workspace module graph proves the build
selects it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f9aeTFZLxrPhMwPH9kqv4
Merged via the queue into main with commit 3a5f0d5 Oct 28, 2026
49 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the fix/golang-workspace-model branch October 28, 2026 20:19
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request 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>
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.

4 participants