Skip to content

Fix gem takeover un-hosting a grouped gem (#775) - #776

Open
Mikola Lysenko (mikolalysenko) wants to merge 10 commits into
mainfrom
agent/fix-gem-takeover-declaration-preflight
Open

Mikola Lysenko (mikolalysenko) wants to merge 10 commits into
mainfrom
agent/fix-gem-takeover-declaration-preflight

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #775

Summary

When a hosted gem is declared inside a group … do block, scan --mode vendored / get --mode vendored no longer un-host it and then refuse it. The takeover now asks the gem backend's Gemfile declaration gate before it restores the hosted pin:

  • Wet run: failed gemfile_declaration_not_editable (exit 1 / partial_failure). The hosted Gemfile / Gemfile.lock are byte-untouched, so the gem stays hosted-patched.
  • Dry run: the preview reports the gem as would_refuse with errorCode: gemfile_declaration_not_editable (exit 0, the same posture as the Bun/vlt would_refuse rows). It no longer says would_vendor.

socket-patch vendor (eject) already rolled back correctly and is unchanged.

Root cause

The hosted→vendored takeover (vendor_records_reusing in commands/vendor.rs) runs restore_upstream, which writes the upstream Gemfile + Gemfile.lock, before the gem vendored backend runs. The only gem gate before the restore was gem_manifest_refusal (a gems.rb twin or BUNDLE_GEMFILE). The declaration gate (plan_gemfile_edit / refuse_append_of_direct_dependency) only ran inside the backend, after the restore had been written. The dry-run preview is a ledger classification with no engine refusals, so it could not see the problem either.

Changes

  • socket-patch-core/src/vendor/gem.rs: new gem_vendor_target_preflight, the gem twin of yarn_berry_vendor_target_preflight. It runs a dry-run restore_upstream of the pin and evaluates the declaration gate on the restored Gemfile / Gemfile.lock text. That way socket-patch's own hosted source … do block is never mistaken for the user's declaration. It never writes.
  • socket-patch-cli/src/commands/vendor.rs: gem_takeover_refusal (manifest gate, then the declaration preflight) is used by the takeover loop before the restore. gem_takeover_preview_refusals does the same for the dry-run preview.
  • scan/vendor_flow.rs, scan/mod.rs, get.rs: preview_vendor_json takes the resolved takeover refusals and renders them as would_refuse rows (JSON and the human [would-refuse] lines).
  • get.rs (lock_text_refusals_for) and scan/vendor_flow.rs (preflight_refused_purls): the wet download phase and scan's rollout planning pass apply the same gem takeover refusal, so a refused gem is reported before its patch view is fetched and never takes a rollout slot (Bugbot finding on b8b7752).
  • CLI_CONTRACT.md: new "Gem preflight before the takeover" paragraph under "Takeover reconciliation".

No wrapper changes are needed: npm/, pypi/ and gem/ only dispatch to the binary.

Test evidence

Issue Test Red without fix → green with fix
#775 (wet get/scan --mode vendored) e2e_redirect_gem_build::gem_hosted_group_block_pin_survives_a_refused_vendored_takeover (real Bundler 4.0.17) Red: vendor_takeover_reverted_redirect emitted, Gemfile/lock un-hosted. Green: failed gemfile_declaration_not_editable, files byte-identical.
#775 (dry run) same test, dry_run=true legs for get and scan Red: "action": "would_vendor". Green: would_refuse + errorCode.
preflight logic vendor::gem::tests::takeover_preflight_* (3 tests: group block refused and nothing written, top-level hosted gem passes, a refused restore is left to the takeover) new
preview rendering scan::vendor_flow::preview_tests::preview_marks_a_refused_takeover_would_refuse new

Red was shown by stubbing gem_vendor_target_preflight to return None. Both the dry legs and the wet leg failed exactly as reported in #775.

The e2e respells the mock upstream as https://rubygems.org/ in the hosted pair and serves it through SOCKET_RUBYGEMS_URL. The takeover restore only re-derives a CHECKSUMS sha256 for a rubygems.org remote, which is the shape of the report.

Commands run locally:

  • cargo clippy --workspace --all-features -- -D warnings: clean.
  • cargo test -p socket-patch-cli --all-features --test e2e_redirect_gem_build --test e2e_vendor_gem_build -- --ignored: 12 + 6 passed.
  • cargo test --workspace --all-features: all pass except 16 write-failure tests across 4 binaries. Those depend on chmod making files unwritable, which does not hold when running as root (uid 0) in this sandbox. They do not touch the changed code, and CI runs them non-root.
  • Formatting: main is not cargo fmt-clean under the pinned toolchain, and CI has no fmt check. Only the hunks this PR changes are rustfmt-formatted, so the diff stays reviewable.

Follow-ups (not in this PR)

🤖 Generated with Claude Code

https://claude.ai/code/session_0145M3bGaAVYdPzNXXD75tqe


Note

Medium Risk
Takeover ordering changes affect hosted gem projects switching to vendored mode; incorrect preflight could leave pins hosted or still un-host in edge cases, but the change aligns with existing Bun/npm preflight patterns and is heavily tested.

Overview
Hosted→vendored takeover for gems now runs the same pre-checks as the gem vendored backend before restore_upstream un-hosts the pin. That covers the manifest gate (gemfile_not_loaded) and the Gemfile declaration gate on the restored Gemfile text via a dry-run restore (gem_vendor_target_preflight), so gems declared inside group blocks stay hosted when vendored mode cannot edit them.

Wet runs fail with gemfile_declaration_not_editable (or other gem codes) with Gemfile / Gemfile.lock unchanged. Dry-run scan / get --mode vendored adds would_refuse preview rows instead of would_vendor. The download phase and rollout planning apply the same refusals so refused gems are not fetched early.

CLI_CONTRACT.md documents the gem preflight alongside Bun/npm takeover rules. E2E covers the group-block fixture (ScanVexGroupBlock).

A small refactor routes Gradle/Maven JVM sha1/sha256 hashing through crate::utils::digest in gradle_cache, jvm_jar, and maven sidecars (behavior unchanged).

Reviewed by Cursor Bugbot for commit ecd761e. Configure here.


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
Vendored mode cannot edit a gem declared inside a `group` block (or
any other declaration its line grammar refuses), but the hosted ->
vendored takeover only finds that out after it has restored the
hosted pin. gem_vendor_target_preflight runs the backend's Gemfile
declaration gate on the Gemfile and Gemfile.lock text the restore
would leave, without writing anything, so the takeover can refuse
first (#775).

Assisted-by: Claude Code:claude-opus-5-5
`scan --mode vendored` and `get --mode vendored` over a hosted gem
declared inside a `group ... do` block restored its upstream entry and
only then hit `gemfile_declaration_not_editable`, so the gem ended up
neither hosted nor vendored and the next frozen install loaded the
unpatched gem. The dry run promised `would_vendor`.

The takeover now asks the gem declaration preflight before the
restore: the wet run fails `gemfile_declaration_not_editable` with the
hosted Gemfile and Gemfile.lock untouched, and the dry-run preview
reports the gem as `would_refuse` with the same code (#775).

Fixes #775

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 4, 2026 12:15
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


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

Copy link
Copy Markdown
Collaborator Author

[agent] Ready for review at ec8f5f1.

  • CI: 491/491 check runs finished (485 success, 6 skipped), 0 failing.
  • Bugbot: reviewed ec8f5f1, no inline findings.
  • Reviewer focus: gem_vendor_target_preflight in vendor/gem.rs (a dry-run restore_upstream and the declaration gate run before the takeover writes anything) and the new would_refuse preview rows.

Generated by Claude Code

@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 5, 2026
Conflicts: kept both sides' additions — CLI_CONTRACT takeover paragraph gets main's npm v1 lock sentence and the PR's gem preflight sentence, vendor_flow preview keeps both the gem takeover and npm lock would_refuse arms, and both new test sections/e2e drivers are kept.

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

Copy link
Copy Markdown
Collaborator Author

[agent] coverage is red on 5b14678 (the merge of main into this branch), but the failure comes from main, not this PR.

Failing tests (-p socket-patch-cli --lib):

  • commands::vex_consumed::tests::hosted_expands_alias_only_copies (vex_consumed.rs:771)
  • commands::vex_consumed::tests::hosted_reuses_expanded_npm_copies_and_merges_alias_variants (vex_consumed.rs:718)

Why it isn't this PR's: the same two tests fail on origin/main at 4646693 (reproduced locally), and main's own coverage check on 4646693 is red. This PR doesn't touch vex_consumed.rs or the npm discovery code.

Cause: the two merges conflict in meaning. #738 added these tests assuming find_manifest_package_copies_reusing misses aliased and nested-store copies, so that tracked_npm_hosted has to expand them. #605, merged afterwards, makes discovery find those copies directly. installed is no longer empty in the first test and already holds the alias and nested peers in the second.

Fix: I found no open PR for this. A minimal patch would update the two tests to #605's discovery: drop the installed.is_empty() assert and expect the alias and nested-store copies in installed, with no extra expansion call. The other option is to narrow #605 if those tests are the intended contract. Which one is a call for whoever owns #605/#738, so I'm not putting it in this PR. I'll port the fix here once it exists.

I didn't re-run the job: the failure is deterministic on main, so a re-run would fail the same way.


Generated by Claude Code

Brings in the vex_consumed alias test fix (#849) that main's red
test/test-release/coverage jobs were waiting on.

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.

Comment thread crates/socket-patch-cli/src/commands/scan/vendor_flow.rs
The dry-run preview already marked gems whose hosted->vendored takeover
the gem gates refuse as would_refuse, but preflight_refused_purls only
consulted the Bun, vlt and npm lock gates. A wet scan therefore kept
those gems in the rollout set and downloaded them before the takeover
refused. The planning pass now applies the same gem takeover gate.

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.

The dry-run preview already reported a hosted gem whose vendored
takeover the Gemfile declaration gate refuses (#775) as would_refuse,
but the wet `scan` / `get --mode vendored` still planned it a rollout
slot and fetched its patch view before the takeover refused it.

The download phase's lock-text refusals now include the gem takeover
refusal for hosted gems, so the view is never fetched, and scan's
vendored planning pass leaves those gems out of the rollout. The e2e
asserts that a wet scan fetches no view for the refused gem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145M3bGaAVYdPzNXXD75tqe
…on-preflight' into agent/fix-gem-takeover-declaration-preflight

# Conflicts:
#	crates/socket-patch-cli/src/commands/scan/vendor_flow.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.

Stale Bugbot comment from a previous run.

Resolved conflicts:
- scan/vendor_flow.rs: kept main's new with_symlink_warnings helper and
  this branch's preflight_refused_purls doc (which also covers the gem
  takeover gate) and GlobalArgs signature. Updated main's new
  preview_vendor_json unit test to pass the branch's takeover_refusals
  argument (empty map).
- tests/e2e_redirect_gem_build.rs: kept both new drivers
  (ScanVexGroupBlock from this branch, ScanVexCustomGitSource from main)
  in the enum, label match and redirect arm.

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 has failed socket-patch-core's lib tests since Gradle support
(#646) and the digest helpers (#865) both landed. The guard test
production_digests_go_through_the_helpers flags three files #646 added
that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and
patch/sidecars/maven.rs. That breaks test, test-release and coverage on
every open PR.

Each inline sha1/sha256 call now goes through sha1_hex_of or
sha256_hex_of, which compute the same lowercase hex. Behaviour is
unchanged.

Assisted-by: Claude Code:claude-opus-5-5
(cherry picked from commit 659ac2c)
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] coverage failed on 90fadc4 (the latest merge of main) because of main, not this PR. I've ported the fix.

Failing test: socket-patch-core utils::digest::tests::production_digests_go_through_the_helpers

Why it isn't this PR's: the test fails the same way on origin/main at 9c43dfc (reproduced locally), and main's own coverage check is red. #865's new guard test flags three files that #646 added and that still compute digests inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. This PR touches none of them.

Ported fix: I cherry-picked #878 (659ac2c, "Route Gradle digests through utils::digest") as ecd761e. With it, the guard test passes and clippy is clean. The only failures left in the core lib tests are the four chmod-based write-failure tests, which fail only because this sandbox runs as root. The port becomes a no-op once #878 lands on main.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] The red checks on ecd761e (lock-diff, plus the cancelled clippy, test, coverage-merge, e2e-build and the compatibility matrices) come from a runner outage, not from code.

What failed: the cancelled jobs carry the annotation "The job was not acquired by Runner of type hosted even after multiple attempts", so no test code ran. lock-diff failed only because the vlt jobs it compares never produced any vlt-results-* artifacts ("locks from no OS"). None of the finished runs on this commit has a real job failure; every non-success is a runner-acquisition cancellation.

Done: I re-ran the cancelled jobs once in each finished run (npm, Gradle, pnpm, Go and Benchmarks). The PR #776 CodeQL run can't be retried (the API returned 403). The CI and vlt runs are still queued, so I can't re-run them yet.


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.

✅ 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 ecd761e. Configure here.

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

Copy link
Copy Markdown
Collaborator Author

[agent] Ready for review at ecd761e. CI: all workflow checks are green, including the Gradle hosted Windows legs that were still running last hour. The only non-green items are CodeQL default-setup Analyze (actions) and Analyze (javascript-typescript), which the runner outage cancelled. They aren't required and the API won't re-run them. Bugbot reviewed ecd761e with no findings. No open review threads. The branch is 3 commits behind main but has no conflicts.


Generated by Claude Code

This branch has not been deployed

No deployments
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

3 participants