Skip to content

Fix vendoring a gem the lock does not resolve (#2075) - #2079

Open
Mikola Lysenko (mikolalysenko) wants to merge 5 commits into
mainfrom
agent/fix-vendor-gem-version-not-locked
Open

Mikola Lysenko (mikolalysenko) wants to merge 5 commits into
mainfrom
agent/fix-vendor-gem-version-not-locked

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-fable-5-2

Fixes #2075

Summary

scan --mode vendored on a Bundler project that installs into a shared gem
home (the Bundler default) failed with exit 1 as soon as that gem home held a
patchable gem version the project doesn't lock: a gem Ruby ships (rake,
rexml), or a version another project installed.

Error: Failed to vendor pkg:gem/rake@13.0.6: failed to edit Gemfile.lock: Gemfile.lock GEM specs has no entry `rake (13.0.6)`
Vendored 1 package; 1 failed.

Such a gem is now skipped, before its patch is downloaded, and the run exits 0:

Downloading 2 patches...
  [skip] pkg:gem/rake@13.0.6 (vendor_gem_version_not_locked): Gemfile.lock does not resolve rake 13.0.6: this project doesn't lock that version, so there is nothing to vendor

Nothing the project does lock changes: those gems are vendored exactly as
before.

Root cause

The crawl of a shared gem home returns every gem version in it. Vendoring a
gem is a Gemfile.lock edit (its spec block moves from a GEM section to a
PATH section), and vendored mode handed the backend versions whose spec the
lock doesn't have. The lock edit failed, which fails the run. Hosted mode has
skipped these since #1055 (redirect_gem_version_not_locked); vendored mode
had no such check.

The change

  • Core (vendor/gem.rs, vendor/refusals.rs, formats/gem/mod.rs): the
    gem backend refuses a version that no section of Gemfile.lock lists, with
    the new code vendor_gem_version_not_locked. The same check is one of the
    vendored refusals that every entry point reads before any download. It uses
    the existing lock parser (listed_specs, next to hosted mode's
    locked_specs).
  • CLI: the code is a benign skip (refusal_is_benign, moved into core so get can read it without importing the vendor command module), so vendor,
    scan --mode vendored and get --mode vendored (by purl and by uuid)
    report a skipped event and exit 0. The dry run prints the same [skip]
    line and counts it as "would be skipped", not "would be refused".
  • Docs: the code's row in CLI_CONTRACT.md, the dry-run headline there,
    and a paragraph in docs/ecosystems.md.

What counts as "the lock resolves it": a spec entry for that name and version
in any source section (GEM, PATH, GIT, PLUGIN SOURCE), including a
platform build of that version. So this PR only changes the outcome for a
version the lock doesn't mention at all. With no readable Gemfile.lock, or a
manifest Bundler doesn't load (gems.rb, BUNDLE_GEMFILE), the backend's own
refusal is reported as before.

Evidence

Red before the fix (commit c5aac79, tests only), on unfixed source:

cargo test -p socket-patch-cli --test in_process_vendor -- version_the_lock unlocked_version
test result: FAILED. 0 passed; 3 failed
  "errorCode": "apply_failed",
  "error": "failed to edit Gemfile.lock: Gemfile.lock GEM specs has no entry `demo-gem (1.0.0)`"

Green after. Tests that cover Fixes #2075, each over three projects (the
project doesn't use the gem; the lock resolves a newer version; the same with
a CRLF lock):

Test (crates/socket-patch-cli/tests/in_process_vendor.rs) Checks
scan_vendor_gem_skips_a_version_the_lock_does_not_resolve exit 0, status: success, skipped event, Gemfile pair byte-identical, no .socket/, patch never downloaded
scan_vendor_gem_dry_run_previews_the_unlocked_version_skip the dry run reports the skip, not "would vendor"
scan_vendor_gem_unlocked_version_skip_reads_as_a_skip human output, wet and dry: one [skip] line, no error
vendor_gem_skips_a_version_the_lock_does_not_resolve vendor and vendor --dry-run from a manifest record
get_uuid_vendored_gem_skips_a_version_the_lock_does_not_resolve get <uuid> --mode vendored, JSON and human

Core unit tests: vendor::gem::tests::version_the_lock_does_not_resolve_is_refused_untouched,
listed_specs_reads_spec_entries_of_every_section,
version_not_locked_refusals_*, vendor::refusals::tests::gem::*, and
scan::render::tests::dry_run_line_counts_expected_skips.

Against a real shared gem home (Ruby 3.3.6, Bundler 4.0.18, Ruby's bundled
rake 13.1.0, a project that doesn't lock it):

$ socket-patch vendor --offline
Skipping pkg:gem/rake@13.1.0: Gemfile.lock does not resolve rake 13.1.0 ...
Vendored 0 packages; 1 skipped.
exit=0          # Gemfile.lock unchanged

Local checks:

  • cargo clippy --locked --workspace --all-features -- -D warnings (CI's
    command): clean.
  • cargo test -p socket-patch-core --lib, cargo test -p socket-patch-cli --lib --bins: pass.
  • in_process_vendor, scan_vendor_e2e, e2e_redirect_gem_stale_install,
    e2e_vex_vendor, vendor_group_commit_e2e, command_module_layering: pass.
  • cargo test -p socket-patch-cli --test e2e_vendor_gem_build -- --ignored
    (real Bundler 4.0.18, Ruby 3.3.6): 11 passed.

Notes for the reviewer

  • One existing test changed its fixture. test_unwind_on_lock_edit_failure
    used a version skew to make the lock edit fail after the Gemfile was
    rewritten. That input is now refused earlier, so the test uses a spec listed
    under two GEM sections, which still fails at the lock edit. Its assertions
    are unchanged.
  • get <uuid> --mode vendored now exits 0 for this code. Every other
    pre-download refusal there is still an error. I made it a skip so the same
    condition isn't failed on one path and skipped on the others.
  • cargo clippy --all-targets reports lints in test code this PR doesn't
    touch (nuget_feed.rs, crawler_npm_e2e.rs, repair.rs, ...); the same
    lints are on main. None are in the files changed here.

Review findings not fixed here

/code-review high ran on the diff; its findings are fixed except two,
which are separate behavior:

  • A gem the lock resolves from a GIT or PATH section, with a registry copy
    of the same version in the shared gem home, still fails in the backend as it
    does on main. Hosted mode skips it with redirect_gem_source_option.
    Skipping it here needs its own message and tests.
  • A Gemfile with no Gemfile.lock still fails each crawled gem with
    vendor_lockfile_missing, where hosted mode skips with
    redirect_gem_no_lockfile.

Filtering the gem selection by the lock once, for both modes, would replace
both per-mode checks. It changes what scan discovers and reports, so it is
left for a maintainer to decide.

https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x


Generated by Claude Code

Refs #2075.

Assisted-by: Claude Code:claude-fable-5-2
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x
Three failing tests for #2075: scan --mode vendored (wet and dry
run) and vendor on a project whose gem home holds a patchable gem
version that Gemfile.lock doesn't resolve. Today the lock edit fails
with "GEM specs has no entry" and the run exits 1.

Assisted-by: Claude Code:claude-fable-5-2
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x
A project that installs into a shared gem home is crawled together
with the gems Ruby ships and the versions other projects installed.
Vendored mode handed each of them to the gem backend, whose lock edit
failed with "Gemfile.lock GEM specs has no entry", so the run exited
1 although nothing in the project could be vendored. Hosted mode has
skipped such a version since #1055.

The gem backend now refuses a version no section of Gemfile.lock
lists as vendor_gem_version_not_locked, and the vendored refusals
raise the same refusal for an installed gem before any download.
vendor, scan and get report it as a skip and exit 0; the dry run
previews it. get <uuid> still reports it as its error.

The unwind test that used a version skew to make the lock edit fail
now uses a spec listed under two sources, which still fails there.

Fixes #2075

Assisted-by: Claude Code:claude-fable-5-2
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x
Follow-up to the review of the previous commit.

The backend refuses an unlocked gem whether or not it is installed,
so the pre-download refusal no longer filters on the installed copy
(which also cost a second crawl of the gem home), and the detail no
longer claims the gem is installed.

The lock is read with the shared Bundler lock parser (listed_specs,
beside hosted mode's locked_specs) instead of a second line scan.

get <uuid> --mode vendored reports the skip as a skip (exit 0), like
the search path, scan and vendor. The dry run prints the wet run's
[skip] line and counts it as "would be skipped", not refused.

Refs #2075

Assisted-by: Claude Code:claude-fable-5-2
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review November 14, 2026 06:03
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] bugbot run


Generated by Claude Code

@cursor

cursor Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

PR Summary

Low Risk
Behavior change is scoped to vendored gem preflight and user-visible skip semantics; no auth, lockfile rewrite, or cross-ecosystem pipeline changes beyond treating one refusal code as non-fatal.

Overview
Vendored workflows (scan, get, vendor) no longer fail when discovery surfaces a Ruby gem version present in the shared gem home but absent from the project's Gemfile.lock. Those packages are refused up front with vendor_gem_version_not_locked, treated as a benign skip (exit 0, no patch download, files unchanged)—matching hosted mode's redirect_gem_version_not_locked behavior.

The core adds lock parsing via listed_specs and pre-download refusal wiring in gem_version_not_locked_refusals / the vendored refusals pipeline; the gem backend also refuses at vendor time before touching the lock. Dry-run output and headlines now distinguish "would be skipped" from hard refusals, printing [skip] lines for benign cases instead of [would-refuse].

Documentation updates cover the new warning code, dry-run wording, and shared-gem-home behavior in CLI_CONTRACT.md and docs/ecosystems.md.

Reviewed by Cursor Bugbot for commit e537285. Configure here.

get.rs called vendor::refusal_is_benign, an import from one command
module into another that command_module_layering forbids (coverage
job). Every input of the predicate is a core constant, so it now
lives beside the refusals in socket-patch-core, and vendor, scan, get
and the download phase all read it there.

Refs #2075

Assisted-by: Claude Code:claude-fable-5-2
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants