Skip to content

Read the governing go.work in Go discovery - #2011

Open
Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
mainfrom
fix/go-discovery-workspace
Open

Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
mainfrom
fix/go-discovery-workspace

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem (G1: which go.work and which members count)

Go VEX discovery read only a go.work at the project root, and only the members inside the root. With GOWORK unset it ignored an ancestor go.work and read the root go.mod in module mode. Hosted mode resolves the workspace go itself would use and refuses to write a replace on host_conflict (patch/redirect/mod.rs, hosted/engine.rs::read_go_workspace), and vendored mode runs the same check (vendor/golang.rs → go_consumer_sync::replace_refusal). So in a member of a parent workspace, discovery could attest a root go.mod replace that the workspace overrides, doesn't use, or makes inert by version selection. Discovery also never read a use ../svc member outside the root, so that member's higher require or conflicting replace was invisible.

Fix

  • On disk, vex/discover/golang.rs loads the governing workspace with go_workspace::load_with, the loader hosted mode uses. It resolves GOWORK (environment, then the go env -w file, read the same way extract already read it), else the nearest go.work at or above the root, with members anywhere. It then reads the module graph with read_graph, as hosted mode does.

    • Out-of-root members' requires now count toward selection, and their replaces toward conflicts.
    • Only the in-root members' go.mod is re-read through the view, so Socket-identity recognition and lockfile_unparseable diagnostics don't change. The project's own sum files are read once, by SumPins. Every other text the loader read is used as it read it.
    • The paths resolve_with probes are declared to a tracked snapshot. A loaded workspace goes raw, as before, because the module graph reads the module cache.
  • Gate: before a hosted or vendored ref is pushed, Workspace::bypass runs:

    • for go.mod refs: GoWorkspace::conflict(<module dir>, …);
    • for root go.work refs: work_conflict.

    These are the checks Loaded::host_conflict applies before hosted or vendored mode writes. A root go.work that go doesn't read, because GOWORK names another file, also counts as a bypass.

  • In memory (ProjectView::Memory) there is no directory to search from and no environment the project builds under:

    • The workspace is the root go.work and its in-root members, exactly as the in-memory hosted engine builds it, and the same gate runs on it. That gate is evaluated in go_workspace::PathSpace::Memory: paths are compared lexically against the view's files and never against the filesystem or the working directory.
    • GOWORK keeps its previous in-memory effect: off reads no go.work, and any other explicit value withholds every ref. GOFLAGS is still not read there.
  • withhold (every ref becomes patched_ref_invalid) now covers only Loaded::Invalid, where go refuses to build: a relative GOWORK, a go.work it can't read or parse (including an unparseable root go.work, which used to fall back to module mode), and the existing -modfile case (Go apply, apply --check and vex ignore GOFLAGS=-modfile, so they report a patch as applied while go builds from another go.mod and links the unpatched module #1799).

Why unattested instead of diagnosed

The gated replaces are live Socket wiring. Hosted and vendored modes refuse to rewrite them while the workspace conflicts, so re-running scan/vendor can never clear the evidence. That is the case Unattested exists for.

So the ref is kept (list, rollback and remove still find it) and marked with a new UnattestedKind::GoWorkspaceOverride. The CLI note arm in vex_sources.rs reports it under the existing go_workspace_override reason. That is the reason vex's own go.mod gate (vex.rs, workspace_audit) already gives for the same cause, so users see one code for one cause. The const moved to vex_sources.rs and vex.rs imports it.

Version-selection inertness (an out-of-root or ancestor member raising the module) is still diagnosed, never a ref, as in module mode. A stale pin is something the writers treat as dead: re-running scan rewires the selected version.

Tests

New tests. Each fails on origin/main (verified by running them against main's golang.rs) and passes here:

  • ancestor_go_work_governs_the_root_module: an ancestor go.work that overrides the root replace, doesn't use the module, or has a member raising it to v1.5.0 (inert diagnostic); plus a clean case and a GOWORK=off case.
  • gowork_naming_another_workspace_governs: GOWORK names an existing workspace elsewhere that overrides, doesn't use, or cleanly uses the module. A root go.work holding the wiring while GOWORK points elsewhere is unattested.
  • workspace_member_outside_the_root_is_read: a use ../svc member that raises the module (inert), replaces it elsewhere (MemberConflict, unattested), or requires the same version (attested).
  • in_memory_root_go_work_governs_the_root_module: an in-memory root go.work that doesn't use the root module.

Changed test: gowork_naming_another_workspace_attests_nothing → gowork_go_refuses_attests_nothing. A GOWORK naming a missing file now reports the loader's "cannot be read" reason instead of "other than".

  • cargo test -p socket-patch-core --lib -- vex::discover:: vendor::go patch::redirect:: hosted::: 1689 passed, 0 failed (after the review fixes)
  • cargo test -p socket-patch-cli --lib -- commands::vex: 35 passed
  • Golden test vex::discover::testing::golden::tests::committed_fixture_corpus_matches_golden: passes, no golden changes
  • cargo clippy --locked --workspace --all-features -- -D warnings: clean
  • cargo fmt --all -- --check: clean

Notes / not done

  • What changed for GOWORK pointing elsewhere. On main, discovery never attested in this case: it withheld every ref as patched_ref_invalid, so the new test fails on main because the ref is dropped rather than kept as unattested. Now discovery reads that workspace. Where hosted mode would write the replace, it attests; where it wouldn't, the ref stays and is unattested.
  • At the vex command level, the existing go_workspace_override and selection gates in vex.rs already withheld most of the go.mod cases. They rebuild the env-aware workspace themselves. This PR fixes the discovery contract that other consumers rely on, for example the hosted rollout's recorded pins and the VEX plan.
  • -modfile and an invalid GOWORK still withhold refs as diagnostics (Go apply, apply --check and vex ignore GOFLAGS=-modfile, so they report a patch as applied while go builds from another go.mod and links the unpatched module #1799 behavior) instead of marking them unattested. Changing them would alter an existing contract this PR doesn't need to touch.
  • Hosted mode's in-memory engine still calls the disk-space GoWorkspace::conflict with an empty root, which probes the working directory. Discovery no longer does (review finding 5); moving hosted to PathSpace::Memory is a follow-up.

Review findings (/code-review high, fixed in 2984629)

I checked each finding first. Every behavior fix has a test that fails on 3f452d1 and passes now.

  1. Confirmed and fixed: a go.work mark vetoed the live go.mod mirror. UnattestedKind::file_scoped() is true only for GoWorkspaceOverride. In vex_sources.rs, such a mark now:

    • drops only the bypassed ref from the candidate's basis;
    • vetoes the patch only when no live ref of it remains;
    • is skipped by wiring_conflicts.

    Test: a_go_workspace_mark_withholds_only_its_own_file (CLI).

  2. Confirmed and fixed: a replace of a use-member module became an unattested ref. The ordering came from 3f452d1, which moved the bypass ahead of selection. A replace of a module that is itself a use member is now diagnosed as inert ("the build selects (the workspace module in …)") before the bypass runs. This covers disk and memory, hosted and vendored. Other bypassed replaces still skip the inertness check, as 3f452d1 intended. Test: replace_of_a_workspace_module_is_inert.

  3. Confirmed and fixed: sum files outside the project were ignored. SumPins now also takes the loader's already-read texts of the governing go.work.sum (when that go.work isn't the root's) and each out-of-root member's go.sum. They are used only when they lie inside the git checkout (find_git_repo, or the project itself when there is none). Test: sum_files_outside_the_project_still_decide_the_pin, which puts a malformed line in the ancestor go.work.sum or the out-of-root member's go.sum.

  4. Aligned, by a different route than suggested. The mark itself stays: a member pin that a Socket go.work replace of another target overrides is not what the build links, so marking that file is accurate. What vex.rs exempts is the purl-wide veto, and with finding 1's file scoping the mark can no longer veto or conflict the go.work ref. The go.work patch now attests instead of becoming a wiring_conflict. Test: a_bypassed_leftover_pin_is_no_wiring_conflict (CLI).

  5. Confirmed and fixed: in-memory checks touched the disk. New go_workspace::PathSpace { Disk, Memory { files } }. conflict, work_conflict and rooted_at delegate to *_in variants, and Disk behaves exactly as before. In memory (and under withhold), paths are matched lexically against the view's files, so nothing touches the filesystem or the working directory. Test: in_memory_workspace_check_reads_only_the_view. It used to say "has nowhere to go" because the crate directory has no go.mod; it now says "go.work does not use this module".

  6. Partly fixed. The probe list is built by one shared helper, workspace_probes, used by both extract_goenv and extract_gowork. It is spelled the way resolve_with probes (current_dir().join(root)), so a relative root's real ancestors are declared too.

  7. Confirmed and fixed: a go.work go never reads was judged unparseable. When GOWORK names another workspace, the root go.work is only normalized, never checked for block structure. Its wiring stays an unattested ref and no lockfile_unparseable is reported. Test: unread_root_go_work_is_not_diagnosed_unparseable, with an unclosed replace ( block.

  8. Fixed as wording. omission_phrase(go_workspace_override) now covers every case: "keeps its replace from the build (overrides, ignores or rejects it, or go builds from a go.work other than the one holding it) … the run warning names which". The per-case detail already rides the run warning. Passing it through failed[].reason would mean adding a detail field to vex::FailedPatch, which is shared by every reason and out of scope here.

  9. Fixed. read_loaded_workspace calls members_within and maps each member back to its index.

  10. Fixed. Dropped the view re-reads of member go.sum and go.work.sum into the model; discovery uses neither, since Member::go_sum only feeds replace_host. The view still reads:

    • the root go.mod and root go.work: recognition (rule 11), and they are the refs' source;
    • the in-root members' go.mod: recognition, parse diagnostics, and refs when the root has no go.mod;
    • the project's sum files, once, in SumPins: recognition and the snapshot's read set.

    Everything else uses the loader's text.

Compatibility with #2025: this PR adds no reads above the repository root. Outside sums are bounded by the checkout. The tests that use a nested project now create a .git beside it, so they stay valid once ancestor go.work files outside the repository are refused.

New tests: 4 core and 2 CLI. All 6 fail on 3f452d1 and pass now.


Note

Medium Risk
Changes Go patch discovery and VEX attestation semantics for workspace layouts; incorrect logic would mis-state which builds run patched code, though the design keeps wiring visible while withholding statements when the workspace blocks the replace.

Overview
Go VEX discovery now loads the workspace the same way hosted/vendored modes do (go_workspace::load_with): nearest/GOWORK workspace, members outside the project, module graph, and sum files inside the repo that govern pins. Replaces the old behavior that only read the root go.work and could attest root go.mod wiring the real workspace overrides or ignores.

Socket replaces the workspace keeps from the build stay discoverable for list / rollback / remove, but are UnattestedKind::GoWorkspaceOverride so vex omits them with go_workspace_override instead of treating them as fully invalid or falsely attested. GOWORK pointing at another readable workspace now follows that model (attest when the writers would write; otherwise unattested) rather than withholding every ref as patched_ref_invalid.

The VEX plan treats that mark as file-scoped: only the bypassed file drops out of the attestation basis; a live go.mod mirror can still attest, and bypassed refs no longer trigger wiring_conflict. Replaces of a module that is itself a use member are diagnosed inert before bypass logic runs.

PathSpace::Memory runs workspace conflict checks lexically on in-memory project views without touching the filesystem. CLI contract and ecosystem docs document the new Go workspace and omission behavior; GO_WORKSPACE_OVERRIDE is centralized in vex_sources.

Reviewed by Cursor Bugbot for commit 1529210. Configure here.



Generated by Claude Code

Go discovery read only a go.work at the project root and only members
inside the root. With GOWORK unset it ignored an ancestor go.work and read
the root go.mod in module mode, so in a member of a parent workspace it
could attest a go.mod replace that the workspace overrides, doesn't `use`,
or makes inert by version selection. Hosted and vendored modes resolve the
workspace go itself would use and refuse to write such a replace.

On disk, discovery now loads the governing workspace with the same
go_workspace::load_with loader hosted mode uses: GOWORK (environment, then
the go env -w file), else the nearest go.work at or above the root, with
`use` members anywhere. Out-of-root members' requires and replaces now
count toward selection and conflicts. In-root members are still re-read
through the view, so recognition and parse diagnostics stay the same.

A wired Socket replace that this workspace keeps from the build, at
exactly the points where hosted mode's host_conflict check refuses, stays a
ref (list, rollback and remove still find it) but is marked unattested
with the new UnattestedKind::GoWorkspaceOverride. The same applies to a
replace in the root go.work while GOWORK makes go build another one.
vex_sources reports these under the existing `go_workspace_override`
reason, the one vex's own go.mod gate already uses. A replace that
workspace selection makes inert is still diagnosed, as in module mode.
A GOWORK that go rejects still withholds every ref as patched_ref_invalid.

In memory there is no directory to search from and no environment the
project builds under. The root go.work and its in-root members are the
workspace there, as the in-memory hosted engine reads them, and the same
conflict gate applies. GOWORK keeps its old effect in memory and GOFLAGS
is not read.

Co-Authored-By: Claude Opus 5.5 <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.

Version-selection inertness in replaced_purl, and the hosted selection
checks, ran before Workspace::bypass. A Socket replace that the
governing go.work also refuses (not used, overridden by a go.work
replace, member conflict) was dropped as inert with a diagnostic
instead of being kept as a GoWorkspaceOverride ref, so list, rollback
and remove could not find wiring that re-running scan or vendor would
never clear. Compute the bypass first and skip the inertness checks for
a bypassed replace; it stays a ref and is reported unattested.

Found by Cursor Bugbot on #2011.

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.

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

Copy link
Copy Markdown
Collaborator Author

Labeled Ready for review (burn-down agent).

  • Head: 3f452d18f44aedddc0a5ae811d64cc2ca9aab06c
  • CI: all green except the e2e_vex_build pdm leg (+ its ci-ok rollup), which is main-wide: hishel 2.0.0 breaks pdm lock on every PR. Fix pdm e2e red on every PR: pin hishel<2 #2028 fixes it.
  • Bugbot: the finding "Overridden replaces dropped as inert" was real and is fixed in 3f452d1 (the workspace bypass is now checked before inertness; regression case added). Bugbot re-reviewed this head with no new findings.
  • Reviewers: the change is in extract_file / replaced_purl in crates/socket-patch-core/src/vex/discover/golang.rs.
  • Slack announcement not sent this run (no Slack send tool available); the next run will retry.

Generated by Claude Code

This fixes the confirmed /code-review high findings on #2011. Each fix
has a test that fails on the previous commit.

- A Go workspace mark now applies to one file only. The new
  UnattestedKind::file_scoped is true only for GoWorkspaceOverride.
  vex_sources drops the bypassed ref from the candidate's basis and
  vetoes the patch only when no live ref of it remains. wiring_conflicts
  ignores bypassed refs. So a root go.work replace that go never reads no
  longer withholds the root go.mod's mirror of it. A leftover member pin
  that Socket's own go.work replace overrides no longer turns into a
  wiring conflict that also withholds the go.work patch, which is what
  vex.rs's WorkHosted exemption intends.
- A replace of a module that is itself a `use` member is diagnosed as
  inert again, both on disk and in memory, hosted and vendored. This
  check now runs before the workspace bypass.
- SumPins now also reads the sum files the loader already read: the
  governing go.work.sum and each out-of-root member's go.sum. They are
  used only when they lie inside the git repository.
- In memory, workspace conflicts are evaluated in
  go_workspace::PathSpace::Memory. Paths are compared lexically against
  the view's files and never against the filesystem or the working
  directory. conflict/work_conflict delegate to the new *_in variants,
  so disk behavior is unchanged.
- The snapshot probe list is now built by one shared helper
  (workspace_probes), spelled the way resolve_with spells it.
- A root go.work that go doesn't read is no longer judged unparseable,
  and its wiring stays an unattested ref.
- The go_workspace_override omission phrase now covers every case.
- read_loaded_workspace uses members_within. The view re-reads only the
  in-root members' go.mod, which recognition and parse diagnostics need.
  The duplicate go.sum and go.work.sum reads are gone.
- Tests that use a nested project now create a .git, so they keep
  working once reads are bounded to the repository root (#2025).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Nov 5, 2026
2984629 dropped every workspace-bypassed ref from wiring_conflicts, so a
leftover member pin that Socket's go.work replace overrides would not
withhold the go.work patch. That also caught the root go.mod. A
GOWORK=off build from the root reads the root go.mod, so a different
patch there still disagrees with go.work about what the build links.
The behavior is the same reasoning ReplaceHost::mirrors_root_go_mod
uses. e2e_vex_lockfile golang_go_mod_and_go_work_disagreeing_attest_neither
went red in coverage (2): it got go_workspace_override plus
go_version_not_selected skips, not one wiring_conflict skip.

Count bypassed refs in the root go.mod as wiring again. Member go.mod
pins are still exempt, as a_bypassed_leftover_pin_is_no_wiring_conflict
requires.

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

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: removed Ready for review. The head had moved to 2984629 and coverage (2) was red there.

Failure: e2e_vex_lockfile::golang::golang_go_mod_and_go_work_disagreeing_attest_neither. In this case the root go.mod wires patch A and go.work wires patch B. The test expects one wiring_conflict skip. It got go_workspace_override + go_version_not_selected instead, because 2984629 drops every workspace-bypassed ref from wiring_conflicts.

Fix (1529210): bypassed refs in the root go.mod count as wiring again, because a GOWORK=off build from the root links them (the same reasoning as ReplaceHost::mirrors_root_go_mod). Bypassed refs in a member go.mod are still exempt, as a_bypassed_leftover_pin_is_no_wiring_conflict requires.

Local runs: e2e_vex_lockfile (354), e2e_vex / e2e_vex_vendor / e2e_vex_redirect, CLI lib (870) all pass. Clippy on lib/bins is clean.

Reviewer: check that the root-vs-member split is the intended product behavior. If go.work should also win over a different root go.mod pin, the e2e test should change instead.


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 1529210. Configure here.

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

Copy link
Copy Markdown
Collaborator Author

Ready for review at head 15292108.

  • CI: 46/48 checks green or skipped. The two red ones, e2e (… e2e_vex_build … pdm:: --ignored …) and ci-ok, fail with module 'hishel' has no attribute 'SyncSqliteStorage' during pdm lock. That's the main-wide pdm/hishel 2 break, which Fix pdm e2e red on every PR: pin hishel<2 #2028 fixes. It isn't caused by this PR.
  • Bugbot: reviewed 15292108, check passed. Its one thread (golang.rs:862) is resolved.
  • CHANGELOG.md is untouched.

For a reviewer: when the root go.mod is itself a wiring conflict, discovery still treats it that way (see commit 1529210).


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief

What it does. Before this PR, Go VEX discovery read only the project's root go.work. Now it reads the workspace go itself would use: GOWORK, else the nearest go.work at or above the root, with members anywhere. It loads that workspace with the same loader hosted and vendored modes use. A Socket replace that this workspace overrides or ignores still shows up as a ref (list/rollback/remove still find it), but it is marked GoWorkspaceOverride and is not attested. The CLI plan drops only the affected file's ref.

Risk: medium. It changes what gets attested for any Go project that sits in a workspace or has an ancestor or GOWORK go.work. Every new path errs toward withholding an attestation, so a false VEX claim is unlikely. The main exposure is missed attestations and the new cross-file logic in vex_sources.

Look here

  • golang.rs:220: extract_gowork, the disk/memory split and load_with
  • golang.rs:507: Workspace::bypass, which mirrors the writers' host_conflict check
  • golang.rs:617: read_loaded_workspace; sum files outside the root are bounded by the git checkout (inside, :649)
  • vex_sources.rs:438: the file-scoped retain_mut (see also :743 wiring_conflicts and :781 bypassed_ref)
  • go_workspace.rs:1182: PathSpace::Memory, which keeps the in-memory checks purely lexical

Verified

  • Read the full diff (7 files).
  • cargo test -p socket-patch-core --lib -- vex::discover::golang: 58 passed.
  • cargo test -p socket-patch-cli --lib -- commands::vex: 35 passed.
  • Traced these by hand and found them correct: a root go.mod mirroring a go.work replace, a leftover member pin under a Socket go.work replace, every mark paired with its ref, and GOWORK=off and unset in memory.
  • CHANGELOG.md is untouched and there's no debug code left in.
  • CI: ci-ok is red only on the known pdm e2e (e2e_vex_build pdm, hishel 2.x), which Fix pdm e2e red on every PR: pin hishel<2 #2028 fixes. clippy is green.

Open questions (non-blocking)

  1. Reads above the repo root. On disk, discovery now reads an ancestor go.work (e.g. ~/go.work) and the go.mod of members outside the root. That matches what go and hosted mode do. But the description says "adds no reads above the repository root", and that only holds once Never read or rewrite project files outside the git repository root #2025 (still a draft) lands. Until then, vex output can depend on files outside the checkout. Merge Never read or rewrite project files outside the git repository root #2025 first, or accept this?
  2. A project with only an ancestor go.work now goes through read_graph plus a raw snapshot, which is not cached. That's a small cost, only on that layout.
  3. There's no Windows-specific test for out-of-root member dirs. On Windows the code falls back to the conservative path.

Auto-merge is not armed: ci-ok has to go green first. Once it's green it gets armed, and approving then sends it straight to the merge queue.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants