Skip to content

Fix gem pins breaking frozen installs with gemspec dev deps (#985) - #1718

Merged
Mikola Lysenko (mikolalysenko) merged 5 commits into
mainfrom
agent/fix-gem-gemspec-dev-dep-pin
Oct 26, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 5 commits into
mainfrom
agent/fix-gem-gemspec-dev-dep-pin

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

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

Fixes #985

Summary

In a gem library, the Gemfile has gemspec, the .gemspec declares add_development_dependency "x", and the Gemfile also declares gem "x" after the directive. In that shape, Bundler 2.7+ merges the two requirements into one Gemfile dependency and locks the union (x (>= 0, = 1.0.0)!). Both socket-patch lock writers wrote the bare x (= 1.0.0)!. Every frozen or deployment install of the committed pair then failed with exit 16, and the scan still reported success.

Root cause

Neither pin writer (hosted formats/gem/hosted.rs converge_gem_lock_source, vendored vendor/gem.rs edit_lock) modelled Bundler 2.7's Dsl#add_dependency merge of a gemspec development dependency into a later gem line.

The details, from Bundler's source and confirmed against real Bundler:

  • The merge happens only when the gem line follows the directive. If the gem line comes first, the Gemfile dependency wins unmerged.
  • The merge happens only on Bundler 2.7 and later.
  • to_lock writes the merged requirement list sorted, then reversed.

Fix

  • New shared reader formats/gem/gemspec.rs: locked_exact_requirement returns the requirement Bundler locks for an exact pin. It checks the lock's BUNDLED WITH for 2.7+, looks for gemspec directives before the gem's declaration (honouring path: and name:), finds the gemspec the way Bundler does ({,*}.gemspec), reads literal add_development_dependency calls (including %q<>, .freeze and array forms), and builds the union in Bundler's order. A gemspec it cannot read returns an error, never a guess.
  • Hosted: the engine reads the directive's gemspecs as advisory input (synthetic <socket-patch:gem-gemspecs> key), and the lock pin carries the merged requirement, so the pair installs frozen. If the gemspec can't be read, the run keeps the exact pin and warns redirect_gem_gemspec_dependency_unreadable. Hosted unwind already restores a consistent pair: the restored gem "x", "1.0.0" re-merges to the unpinned x (>= 0, = 1.0.0).
  • Vendored: the issue expected the merged pin, but there is no lock that works here. With real Bundler 2.7.2 and 4.0.18, a path gem whose gem line follows the directive fails even the lock Bundler writes itself under frozen mode (* rack from vend/rack-3.2.7tovend/rack-3.2.7``). Nothing this backend could write would keep vendored mode's frozen-install promise, so vendoring refuses before any write with gemfile_declaration_not_editable and names the remedy: move the `gem` line above `gemspec` (Bundler then keeps its own requirement, and the pair installs frozen), or use hosted mode. The issue's expected behaviour allows "refuse or warn".
  • Takeover preflight (gem_vendor_target_preflight) runs the same check. A hosted→vendored takeover in this shape now refuses with the hosted pin untouched, in the dry-run preview too, instead of un-hosting the gem and then refusing.
  • CLI_CONTRACT.md documents both modes.

Test evidence

Issue variant Test Red without fix → green with fix
#985 hosted, CHECKSUMS lock patch::redirect::tests::gem_pin_carries_the_gemspec_dev_dependency_bundler_merges (unit) red: left: " rails (= 7.0.0)!" right: " rails (>= 6, = 7.0.0)!" → green
#985 hosted, real Bundler fresh-checkout frozen install e2e_redirect_gem_build::gem_hosted_gemspec_dev_dependency_frozen_install_accepts_the_pin red: lock must pin " vuln-gem (>= 0, = 1.0.0)!" → green on Bundler 4.0.18, 2.7.2 and 2.6.9 (2.6 keeps the exact pin)
#985 vendored vendor::gem::tests::gemspec_dev_dependency_merge_refuses_before_any_write (unit) and e2e_vendor_gem_build::gem_vendor_gemspec_dev_dependency_refuses_or_installs_frozen (real Bundler: refusal leaves files byte-identical; the remedy ordering installs frozen and reverts byte-identical) red: vendor must refuse → green on 4.0.18, 2.7.2 and 2.6.9
Takeover keeps the hosted pin same hosted e2e (get/scan --mode vendored, wet and dry-run) red: get --mode vendored (dry_run=true) reported verified → green
Parser edge cases formats::gem::gemspec::tests (directive options, %q<>/arrays, if/else duplicates, unreadable/ambiguous gemspecs, Bundler version gate, CRLF) —

Commands run locally:

  • cargo fmt --all -- --check: ok
  • cargo clippy --workspace --all-features -- -D warnings: ok
  • cargo test -p socket-patch-core --lib -- gem: 379 passed
  • The two e2e suites' new tests via --ignored on Bundler 4.0.18, 2.7.2 and 2.6.9 (BUNDLER_VERSION + SOCKET_PATCH_BUNDLER_E2E_VERSION)
  • Full cargo test --workspace --all-features --no-fail-fast (local, as root): 234 suites passed. 11 tests outside gem code failed for environmental reasons: 9 simulate write or remove failures with chmod, which root bypasses (partial_lockfile_write_failure_exits_1_and_writes_no_ledger, repair_cleanup_failure_is_reported_in_json_and_silent_modes, wire_write_failure_maps_error_and_leaves_lock_untouched, …), and 2 e2e_bun_lockb tests need patches-api.socket.dev, which the sandbox blocks (tunnel error). CI runs them non-root and with network.

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

Bugbot

  • Fixed in fb0db48: %q()/%q[] mentions of the gem in an unreadable call were treated as absent. Each now counts as a mention, with a regression test.

/code-review high findings

Fixed:

  • The takeover preflight skipped the new refusal.
  • The remedy text was appended to unrelated refusals.
  • A doc comment was misplaced in the e2e file.
  • The contract wording overstated the (>= 0, …) form.

Not changed:

  • "A dev dependency pinned to exactly the vendored version collapses to = v and skips the refusal." Bundler merges only when current.requirement != dep.requirement (Dsl#add_dependency). Equal requirements are not merged: the Gemfile dependency is kept as-is, which is the shape that installs frozen.
  • "vendor --dry-run doesn't preview the refusal." This matches every other Gemfile-plan refusal of the gem backend, which a plain vendor --dry-run also doesn't preview. The hosted→vendored dry-run preview does show it now, through the preflight.
  • "Pairs vendored by an earlier release hit the in-sync fast path unflagged." Out of scope. Follow-up: warn on the hot path when a wired pair has this shape. Fix today: vendor --revert, move the gem line above gemspec, then re-vendor.

https://claude.ai/code/session_017rTaUDxqff57aFGyTyecVD


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
On Bundler 2.7+, a Gemfile `gemspec` directive folds the gemspec's
development dependency on a gem into a later `gem` line for the same
gem, and the lock records the union, e.g. `vuln-gem (>= 0, = 1.0.0)!`.
Both the hosted and the vendored lock writers wrote the bare
`(= 1.0.0)!`, so every frozen or deployment install of the committed
pair failed (exit 16) while the scan reported success.

Both writers now read the gemspec the directive loads and pin the
requirement Bundler derives: the gemspec's requirements plus the exact
version, sorted the way Bundler writes them. Bundler < 2.7 and a `gem`
line before the directive keep the exact pin, as Bundler does. When
the gemspec cannot be read, hosted mode keeps the exact pin and warns
(`redirect_gem_gemspec_dependency_unreadable`); vendored mode refuses
before writing anything.

Fixes #985

Assisted-by: Claude Code:claude-opus-5-5
Real Bundler (2.7.2 and 4.0.18) rejects even its own lock under
frozen mode when a path gem's `gem` line follows a `gemspec`
directive whose gemspec declares the same gem as a development
dependency ("rack from `<path>` to `<path>`"). So no lock vendored
mode can write keeps its frozen-install promise in that shape.

Vendoring now refuses it before any write
(`gemfile_declaration_not_editable`) and names the fix: declare the
gem above `gemspec`, where Bundler keeps its own requirement and the
vendored pair installs frozen, or use hosted mode. Bundler < 2.7 and
a `gem` line before the directive vendor as before.

Adds real-Bundler e2e coverage for both modes: the hosted pair
installs frozen with the merged pin, and the vendored refusal and
its remedy behave as described. Documents both in CLI_CONTRACT.md.

Refs #985

Assisted-by: Claude Code:claude-opus-5-5
The hosted-to-vendored takeover asks the gem backend's refusals
before it restores the hosted pin, so a gem vendored mode cannot
wire keeps its hosted wiring. That preflight did not know about the
new gemspec development dependency refusal: a takeover in that shape
restored the gem upstream and then refused, leaving it unpatched in
both modes. The preflight now runs the same check, so the takeover
(and its dry-run preview) refuses with the hosted pin untouched.

The refusal now names the remedy only when Bundler would merge the
requirements; when the gemspec cannot be read it says so instead.

Refs #985

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 24, 2026 21:40
@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.

A gemspec development dependency spelled `%q(name)` or `%q[name]`
with arguments the reader cannot parse was treated as absent rather
than unreadable, so hosted mode wrote the bare exact pin without a
warning and vendored mode skipped its refusal. Every bracket pair the
literal reader accepts now counts as a mention.

Refs #985

Assisted-by: Claude Code:claude-opus-5-5
@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.

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

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

Copy link
Copy Markdown
Collaborator Author

Labeled Ready for review by the burn-down agent.

  • Head: fb0db48f
  • CI: 37/37 checks green (19 skipped), no main-wide failures; mergeable with no conflicts; no CHANGELOG.md changes
  • Bugbot: reviewed fb0db48 and found no new issues; its one earlier thread on gemspec.rs (at a638242) is resolved/outdated
  • Reviewers: the change pins gem requirements that Bundler 2.7+ merges from gemspec development dependencies, and refuses vendoring a gem merged with a gemspec dep. Look at formats/gem/gemspec.rs (the %q()/%q[] name parsing) and vendor/gem.rs.

Slack announcement could not be sent from this run (no Slack send tool available).


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Labeled Ready for review by the burn-down agent.

  • Head: fb0db48f843b43b816ad092514b23defcc7ff270
  • CI: 37/37 green (19 skipped by scope), no failures.
  • Bugbot: reviewed fb0db48, no new issues. The one earlier thread (%q()/%q[] mentions in gemspec.rs) was fixed in fb0db48 and is resolved.
  • Mergeable against main, no conflicts. No CHANGELOG.md change.
  • Reviewer focus: the new formats/gem/gemspec.rs reader that reproduces Bundler 2.7+'s merge of gemspec dev-dependency requirements, and the vendored-mode refusal (gemfile_declaration_not_editable) when the gem line follows gemspec.

Slack announcement not sent this run (no send tool available); the next run retries.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief

What it does: On Bundler 2.7+, a gem "x" line placed after a gemspec directive whose gemspec has add_development_dependency "x" gets the two requirements merged, so the lock records x (>= 0, = 1.0.0)!. socket-patch wrote a bare x (= 1.0.0)!, so frozen installs failed (exit 16). Hosted mode now writes the merged requirement. Vendored mode refuses before writing anything (gemfile_declaration_not_editable) and tells the user how to fix it, because Bundler rejects even its own lock for a path gem in that shape. Fixes #985.

Risk: low–medium. The change only applies when the lock says BUNDLED WITH ≥ 2.7, there is a gemspec directive, and the gem's line follows it. Otherwise the pin is the old exact one. The gemspec is read with static literal parsing: an unreadable gemspec falls back to the exact pin plus a warning (hosted) or a refusal (vendored), never a guess.

Look here:

Verified:

  • Read the full diff.
  • cargo test -p socket-patch-core --lib -- gem: 379 passed.
  • The branch is up to date with main.
  • CHANGELOG.md is untouched.
  • Bundler's sort order matches: byte-wise sort, same as Ruby's.
  • Equal dev and Gemfile requirements dedup back to the exact pin, matching Dsl#add_dependency.
  • CI 37/37 green (19 skipped by scope).
  • Bugbot found no new issues on fb0db48.
  • All review threads are resolved.

Changes I made: none.

Open questions: none blocking. The follow-up named in the PR body is still open: pairs vendored by an earlier release in this shape aren't flagged.

Auto-merge is armed, so approving sends this straight to the merge queue.


Generated by Claude Code

Merged via the queue into main with commit 5302faf Oct 26, 2026
56 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the agent/fix-gem-gemspec-dev-dep-pin branch October 26, 2026 16:17
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