Skip to content

Fix gem source-block bang lost after x = if … end (#1476) - #1478

Merged
Mikola Lysenko (mikolalysenko) merged 6 commits into
mainfrom
agent/fix-gem-assignment-keyword-block
Oct 13, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 6 commits into
mainfrom
agent/fix-gem-assignment-keyword-block

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

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

Fixes #1476

Summary

Hosted gem rollback / remove now keep the DEPENDENCIES ! (and the
declaration's indent) for a gem declared in the user's own
source "…" do block when an assignment-form keyword construct
(v = if … end, v = case … end, v = begin … end, x ||= unless …)
precedes it. Before this, the restored lock dropped the ! and every
frozen/deployment install on Bundler 4.0.19+ failed with exit 16, while the
unwind reported success.

Root cause

in_source_block (crates/socket-patch-core/src/patch/redirect/upstream/gem.rs)
decides DepEntry::Keep vs Unpin by counting block openers and ends
line by line. opens_keyword_block only counted if/case/begin/…
when the keyword was the first token of the line. In v = if … nothing was
pushed, but the matching end still popped, removing the user's source
frame. declaration_prefix used the same line-level heuristics, so the
restored gem also came back at column 0 after a blank line.

Fix

One block scanner (block_stack) now feeds both in_source_block and
declaration_prefix:

  • word-level: a keyword opens a construct wherever it starts an expression
    (line start, after =, (, ,, ;, a label require:, =>, a
    ternary, and/or/when/then/else, an operator, or a
    continued previous line); end closes one; one-line constructs net out;
  • modifiers (gem "x" if c, x.save! if y, return if y, a modifier on a
    \\-continued line), names (x.class, :if, if:, begin?,
    def begin) and endless defs (def x = 1, def ==(o) = …) open nothing;
    a loop's own do is not a second block;
  • strings, backticks, %w[]/%i()/%q{}/%x()/… and regexp literals
    (multi-line strings and %-literals included), comments, =begin/=end
    blocks and heredoc bodies are not code;
  • the restored declaration's indent comes from the enclosing block's opener
    line (+2); a blank line is still restored before a following declaration.

Tests (red → green)

Issue case Test Before fix After
#1476 v = if … end lock ! + Gemfile byte-identical unit source_block_declaration_keeps_its_bang_through_restore (new manifests: if, case+begin, unless/while, deeper end) FAILED (\n\ngem "rails" at column 0, ! dropped) ok
#1476 scanner shapes (if/unless/case/begin/while/until/for, labels, ternary, literals, heredocs, =begin) unit source_block_detection FAILED ok
layout after an inner end unit manifest_blocks_restore_in_place — ok
#1476 e2e on real Bundler (v = if … end + w = case … end), rollback + remove, frozen install of the restored pair e2e_redirect_gem_build::gem_hosted_unwind_keeps_a_source_block_bang (new ScanVexSourceBlockAssignment driver) issue repro: exit 16 on 4.0.22 ok on Bundler 4.0.22 and 4.0.18

Commands run locally (Ruby 3.3.6):

  • cargo fmt --all -- --check ✅
  • cargo clippy --workspace --all-features -- -D warnings ✅
  • cargo test -p socket-patch-core --all-features --lib redirect::upstream::gem: 17 passed; with the fix reverted, 2 failed
  • BUNDLER_VERSION={4.0.22,4.0.18} SOCKET_PATCH_BUNDLER_E2E_REQUIRED=1 cargo test -p socket-patch-cli --all-features --test e2e_redirect_gem_build --test e2e_vendor_gem_build -- --ignored: 29 + 8 passed on each
  • cargo test --workspace --all-features --no-fail-fast: 14337 passed, 13 failed. All 13 are environmental and outside gem code: 12 use chmod to force write failures, which root ignores (the sandbox runs as uid 0), and e2e_bun_lockb::binary_shared_bundled_record_hosted_pin_is_managed needs the real patch API (tunnel error). CI runs these unprivileged.

No wrapper (npm/, pypi/, gem/) changes: this is core Rust logic only.

Review notes (/code-review high, two passes)

All confirmed findings are fixed in c9f3945 and 9f2c94f. Not changed:

  • vex/discover/gem.rs parse_source_block_decls keeps its own line
    grammar. It reads only Socket-written patch-registry blocks (which hold
    plain gem lines) and fails closed: an early close only drops
    attribution and never adds a ! or an attestation. Sharing the lexer
    there would be a separate refactor.
  • block_stack runs twice per restored direct gem (in_source_block and
    declaration_prefix) over a small Gemfile prefix. Threading one stack
    through restore_one → restore_manifest costs more code than it saves.
  • ruby_words looks like gem_line_tail_blocks_edit's classifier in
    redirect/mod.rs, but the two answer different questions (modifier vs
    argument position on a declaration line), so they stay separate.
  • Remaining text-lexing limits, same as before this PR: #{} interpolation
    that nests quotes, and percent literals after a bare method call
    (puts %w[…]).

🤖 Generated with Claude Code

https://claude.ai/code/session_01HuKqLAEGGwH73DucozCrY6


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
A gem declared in the user's own source "…" do block loses its
DEPENDENCIES `!` on hosted rollback/remove when an assignment-form
keyword construct (v = if/case/begin … end) precedes it, so frozen
installs fail on Bundler 4.0.19+ (#1476). Add unit cases for the
block scanner and the restore round trip, and an e2e Bundler case.

Assisted-by: Claude Code:claude-opus-5-5
The hosted gem unwind only counted if/case/begin/… as opening a block
when the keyword started its line. In `v = if …` nothing was opened,
but the matching `end` still closed the user's source block, so the
restored lock dropped its `!` pin and Bundler 4.0.19+ frozen installs
failed after rollback or remove. The restored declaration also came
back at column 0 after a blank line.

Count keywords word by word wherever they start an expression (after
=, (, ',', ;, …), treat `end` as closing one, net out one-line
constructs, skip modifiers, names like x.class or if:, and endless
defs. Derive the restored declaration's indent from the same block
stack.

Fixes #1476

Assisted-by: Claude Code:claude-opus-5-5
After an `end` indented deeper than the block's body (a case/when
laid out under its assignment), the restored declaration copied that
`end`'s indent, so the Gemfile did not come back byte for byte. Take
the indent of the line that opened the enclosing block + 2 instead.

Assisted-by: Claude Code:claude-opus-5-5
Review follow-ups for the source-block scanner (#1476):
- a keyword after a label (require: if), =>, a ternary, and/or/not or
  an operator starts an expression, so its end is counted;
- %w[]/%i[] arrays, regexps, backticks, =begin/=end comments and
  heredoc bodies are no longer read as code;
- a ; ends a loop's statement, so a later do opens its own block;
- def begin / x.then if treat the keyword-like word as a name;
- the restored declaration keeps a blank line before a following
  declaration after an inner end, as before;
- the stack is computed once per restore, with opener line offsets.

Assisted-by: Claude Code:claude-opus-5-5
Second review pass on the source-block scanner (#1476):
- mask strings and %-literals that span lines, and find the comment
  with the same literal rules (a ' in a regexp, # in %q());
- add %s() / %x() literals;
- class <<self and list <<name are not heredocs;
- a modifier on a backslash-continued line stays a modifier;
- endless defs with operator names or nested parens;
- a multi-line block opener indents from its first line.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 13, 2026 11:45
@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 9f2c94f. Configure here.

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

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: labeled Ready for review at head 9f2c94f.

  • CI: 35/35 green (+50 skipped) on 9f2c94f; no main-wide failures.
  • Bugbot: reviewed 9f2c94f, no findings (a later re-request was cancelled, not failed).
  • Mergeable, no conflicts; does not touch CHANGELOG.md.
  • Reviewer focus: crates/socket-patch-core/src/patch/redirect/upstream/gem.rs keyword/literal scanning for assignment-form x = if … end inside source blocks.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief (merge steward)

What it does: Hosted gem rollback/remove decides whether a restored gem sits inside the user's source "…" do block (and so keeps its ! in the lock's DEPENDENCIES) by counting block openers and ends. Before this PR, assignment-form constructs like v = if … end didn't count as openers but their end still closed the source block, so the ! was dropped and frozen Bundler 4.0.19+ installs failed with exit 16. A single word-level Ruby block scanner (block_stack) now feeds both in_source_block and the restored line's indent.

Risk: medium. The change is only in the gem unwind path, but it replaces a small line-level heuristic with roughly 450 lines of hand-written Ruby lexing (literals, heredocs, =begin, endless defs, modifiers vs. expression-position keywords). Unusual Gemfile syntax could still be misread, as it could before. When that happens, only the !/indent of the restored line is affected.

Look here:

  • block_stack gem.rs:565:`` the core scanner (do/keyword push, `end` pop, `;` statements, carried continuation lines)
  • starts_expression gem.rs:709:``` decides between a modifier (gem "x" if c) and an opener (`v = if c`)
  • masked_ruby_code gem.rs:812 and heredoc_openers gem.rs:940:````` literal, comment and heredoc masking
  • declaration_prefix gem.rs:512:`````` restored-line indent now comes from the enclosing block's opener. Top-level layout is unchanged.
  • e2e gem_hosted_unwind_keeps_a_source_block_bang:``````` real-Bundler rollback/remove plus a frozen install with the new ScanVexSourceBlockAssignment Gemfile

Verified:

  • Read the full diff (2 files, +637/−64): scope is limited to gem.rs and its e2e test. No CHANGELOG, wrapper or debug leftovers. CRLF is handled (split_inclusive('\n') + \r trim).
  • Checked that the top-level declaration_prefix output (blank line, next declaration's indent) matches the old behavior, so only in-block cases change. Those now get better indents, including multi-line group openers and if blocks.
  • Locally on 9f2c94f: cargo test -p socket-patch-core --all-features --lib redirect::upstream::gem gave 17/17 pass. CI's cargo clippy -p socket-patch-core --all-features -D warnings is clean, and so is cargo fmt --check. The --all-targets lints are pre-existing in other files and none are in gem.rs.
  • CI: 35/35 green, 50 path-skipped. ci-ok and clippy are green. Bugbot reviewed 9f2c94f with no findings. No unresolved threads.

Changes I made: none.

Open questions: none blocking. Known lexing limits are listed in the PR description: nested-quote #{} interpolation and puts %w[…] after a bare call. Both are the same as before this PR.

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


Generated by Claude Code

Merged via the queue into main with commit 48ce1f4 Oct 13, 2026
86 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the agent/fix-gem-assignment-keyword-block branch October 13, 2026 14:15
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Oct 13, 2026
Covers #1469, #1470, #1474, #1475, #1478, #1479, #1480 and #1482; #1484 is
CI-only. The notes now cover main through 285a5e8.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
github-merge-queue Bot pushed a commit that referenced this pull request Oct 13, 2026
* ci(release): publish npm from release.yml, not publish-npm.yml

Every socket-patch npm package's trusted publisher is registered against
release.yml, and npm allows only one publisher per package. The 5.0.0
release dispatched publish-npm.yml, whose OIDC token named that file, so
npm rejected it with E401 before staging anything.

Move the npm leg back inline as release.yml's npm-publish job (same steps
as publish-npm.yml) and delete publish-npm.yml, so the existing
registration keeps working. Add an `npm-retry-version` input that skips
the version gate, build, tag, GitHub release and crates.io jobs and
re-stages npm from an existing release's assets; this retries 5.0.0 now
and replaces the manual Publish npm dispatch. crates.io stays dispatched
through publish-cargo.yml, since crates.io allows several publishers per
crate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(release): fold 22 more PRs into the 5.0.0 notes

Cover the PRs merged to main after #1432's base (1779317..4cb3643):
Cargo source replacement and registry = "crates-io" (#338/#455, #386),
pnpm lockfile=false (#1074), berry removed descriptors (#1435), yarnrc
${VAR} reads (#1100), CRLF .npmrc append (#1196), Composer vendor-dir
cascade and global home (#439/#686, #586), Go +incompatible vex (#484),
gem moved BUNDLE_GEMFILE and commented Gemfile revert (#952, #988),
Poetry source = "pypi" (#1282) and Hatch path characters (#547).

No entries for #1411 (macOS fix to code #1393 added in this release;
the #1393 entry already describes the intended behavior), #1426 (perf
regression introduced and fixed within 5.0.0) or the CI/test/bench PRs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(release): cover #1419, #1434, #1440, #1442, #1444 in the 5.0.0 notes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(release): 5.0.1

Cut [Unreleased] into [5.0.1] — 2026-10-13 and stamp 5.0.1 with
scripts/version-sync.sh. The 5.0.1 notes cover main through 501c6d9
(#1449), i.e. every PR after the v5.0.0 tag (6ad6add): the npm
publish fix (5.0.0 never reached npm or crates.io, 5.0.1 is the first
v5 there), #1465, #759, #1460, #1462, #1445, #869, #1447, #1450,
#1467, and #1449 under Maintenance. CI/test-only PRs (#1448, #1452,
#1454, #1457, #1459) have no entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(release): 5.0.1 notes for PRs merged since the cut

Covers #1469, #1470, #1474, #1475, #1478, #1479, #1480 and #1482; #1484 is
CI-only. The notes now cover main through 285a5e8.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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