From bb25d76f82a68fdb8b5bd19e13de1b9a3600825f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:23:46 +0000 Subject: [PATCH 1/4] Start fix for #1531 Assisted-by: Claude Code:claude-opus-5-5 From 15e62ddc98f9de906679435f125119aa2a6bdb5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:33:22 +0000 Subject: [PATCH 2/4] Keep gem ! pin for a source block after a ; `rollback` and `remove` dropped the `!` source pin from a gem's DEPENDENCIES line when its `source "..." do` block followed another statement and a `;` on the same line (`zz = 1; source "..." do`). Bundler 4.0.19+ then failed every frozen install with exit 16. The Gemfile block scanner only checked for a `source` call at the start of a line. It now checks each top-level `;` statement too, so the gem is seen as inside its source block and keeps its pin. Fixes #1531 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_gem_build.rs | 17 ++++++ .../src/patch/redirect/upstream/gem.rs | 56 +++++++++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index f7fda3257..ff0280739 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -446,6 +446,9 @@ enum Driver { /// [`Driver::ScanVexSourceBlock`] as a `source(…) { … }` brace block /// (#1509): bundler pins it with `!` too. ScanVexSourceBlockBraces, + /// [`Driver::ScanVexSourceBlock`] whose `source … do` follows another + /// statement and a `;` on its line (#1531). + ScanVexSourceBlockAfterSemicolon, /// A heredoc option continues beyond the declaration's physical line. ScanVexHeredocDeclaration, /// A double-quoted interpolation can itself contain a heredoc opener. @@ -545,6 +548,9 @@ impl Driver { Driver::ScanVexSourceBlockBraces => { "scan --mode hosted (gem in a `source(…) { }` block)" } + Driver::ScanVexSourceBlockAfterSemicolon => { + "scan --mode hosted (gem in a `x = 1; source … do` block)" + } Driver::ScanVexSemicolonJoinedDeclaration => { "scan --mode hosted (two `;`-joined gem declarations)" } @@ -860,6 +866,11 @@ async fn redirect_scanned_project( gem \"{DEP}\", \"{DEP_VERSION}\"\n}}\n", up = server.uri() ), + Driver::ScanVexSourceBlockAfterSemicolon => format!( + "source \"{up}/upstream\"\n\nzz = 1; source \"{up}/upstream\" do\n \ + gem \"{DEP}\", \"{DEP_VERSION}\"\nend\n", + up = server.uri() + ), Driver::ScanVexEvalGemfile => { std::fs::write(proj.join("Gemfile.common"), format!("gem \"{DEP}\"\n")).unwrap(); format!( @@ -1122,6 +1133,7 @@ async fn redirect_scanned_project( | Driver::ScanVexSourceBlockAssignment | Driver::ScanVexSourceBlockMultiLineOpener | Driver::ScanVexSourceBlockBraces + | Driver::ScanVexSourceBlockAfterSemicolon | Driver::ScanVexCustomGitSource | Driver::ScanVexMultiLineDeclaration | Driver::ScanVexConditionalDeclaration @@ -1329,6 +1341,7 @@ async fn redirect_scanned_project( | Driver::ScanVexSourceBlockAssignment | Driver::ScanVexSourceBlockMultiLineOpener | Driver::ScanVexSourceBlockBraces + | Driver::ScanVexSourceBlockAfterSemicolon | Driver::ScanVexTrailingSemicolonDeclaration => { assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); assert!( @@ -2401,6 +2414,7 @@ async fn gem_hosted_group_block_pin_survives_a_refused_vendored_takeover() { /// `w = case … end` constructs precede the gem inside the block. /// #1509: and when the block's `source(` opener spans lines, or the block /// is a `source(…) { … }` brace block. +/// #1531: and when the `source … do` follows `x = 1;` on its line. #[tokio::test(flavor = "multi_thread")] #[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 2.2); \ the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] @@ -2414,11 +2428,14 @@ async fn gem_hosted_unwind_keeps_a_source_block_bang() { (Driver::ScanVexSourceBlockMultiLineOpener, "remove"), (Driver::ScanVexSourceBlockBraces, "rollback"), (Driver::ScanVexSourceBlockBraces, "remove"), + (Driver::ScanVexSourceBlockAfterSemicolon, "rollback"), + (Driver::ScanVexSourceBlockAfterSemicolon, "remove"), ] { let label = match driver { Driver::ScanVexSourceBlockAssignment => "source-block-assignment", Driver::ScanVexSourceBlockMultiLineOpener => "source-block-multi-line-opener", Driver::ScanVexSourceBlockBraces => "source-block-braces", + Driver::ScanVexSourceBlockAfterSemicolon => "source-block-after-semicolon", _ => "source-block", }; let Some(fx) = redirect_scanned_project( diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs index b5802825f..093eae0d7 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs @@ -624,17 +624,24 @@ fn block_stack(text: &str, at: usize) -> Vec { let masked = masked_ruby_code(line, &mut literal); heredocs.extend(heredoc_openers(line, &masked)); let masked = masked.trim(); + // Whether a statement opens with a `source` call, and how: sets + // `source_pending` and `source_call` for it. + let begin_statement = |statement: &str, pending: &mut bool, call: &mut (bool, bool)| { + let rest = statement.trim_start().strip_prefix("source").unwrap_or("-"); + *pending = rest.starts_with([' ', '\t', '(']); + *call = (false, !rest.starts_with('(')); + }; if !continues { depth = 0; statement_line = line_start; - let rest = masked.strip_prefix("source").unwrap_or("-"); - source_pending = rest.starts_with([' ', '\t', '(']); - source_call = (false, !rest.starts_with('(')); + begin_statement(masked, &mut source_pending, &mut source_call); } for (n, statement) in masked.split(';').enumerate() { let lead = if n == 0 { carried.as_str() } else { "" }; + // A statement after a top-level `;` starts afresh, and may be + // a `source` call of its own (`x = 1; source "…" do`, #1531). if n > 0 && depth == 0 { - source_pending = false; + begin_statement(statement, &mut source_pending, &mut source_call); } // A `while`/`until`/`for` takes its optional `do` as part of // the loop, not as a block of its own. @@ -1943,6 +1950,47 @@ mod tests { } } + #[test] + fn source_block_after_semicolon() { + // #1531: a `source` statement after a `;` on its line opens a + // source block just as a line-leading one does. + let at = |text: &str| in_source_block(text, text.find("gem \"rails\"").unwrap()); + for opener in [ + "zz = 1; source \"https://x\" do", + "zz = 1;source \"https://x\" do", + "a = 1; b = 2; source \"https://x\" do", + "zz = 1; source(\"https://x\") do", + "zz = 1; source(\"https://x\") {", + "zz = 1; source(\n \"https://x\"\n) do", + "zz = 1; source \"https://x\",\n type: :rubygems do", + "git_source(:gh) { |r| \"https://github.com/#{r}.git\" }; source \"https://x\" do", + ] { + let close = if opener.ends_with('{') { "}" } else { "end" }; + let t = + format!("source \"https://rubygems.org\"\n\n{opener}\n gem \"rails\"\n{close}\n"); + assert!(at(&t), "{t}"); + let t = format!( + "source \"https://rubygems.org\"\n\n{opener}\n gem \"a\"\n{close}\ngem \"rails\"\n" + ); + assert!(!at(&t), "{t}"); + } + // Only a statement that begins with `source` is one. + for line in [ + "zz = 1; group :t do", + "zz = 1; x.source \"https://x\" do", + "zz = 1; sources \"https://x\" do", + "foo(1; source \"https://x\") do", + ] { + let t = format!("{line}\n gem \"rails\"\nend\n"); + assert!(!at(&t), "{t}"); + } + // A `;` statement that is not a source call clears a line-leading + // `source` without a block. + assert!(!at( + "source \"https://x\"; group :t do\n gem \"rails\"\nend\n" + )); + } + #[test] fn source_block_detection() { let at = |text: &str| in_source_block(text, text.find("gem \"rails\"").unwrap()); From c51944e0b552402a285c9c57b1e2c1800b52d58c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:05:30 +0000 Subject: [PATCH 3/4] Find a gem source call wherever it starts The first fix only looked for `source` right after a `;`. Bundler also runs a source block opened after `then`, `=` and other expression starts, so rollback could still drop the gem's `!` pin there. The scanner now treats `source` as a call wherever a keyword would start an expression, skips `source = ...` assignments, ends a call made inside a brace block when that block closes, and takes a restored gem's indent from the line its source statement is on. Refs #1531 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/upstream/gem.rs | 94 +++++++++++++------ 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs index 093eae0d7..70fb818ed 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs @@ -569,7 +569,9 @@ struct Frame { /// enclosing block. A `{` opens one that its `}` closes (a brace block or /// a hash). A statement runs on while a `(` or `[` is open, so a `source(` /// whose arguments span lines still owns the `do` or `{` that follows its -/// `)` (#1509). String, `%w[]`, regexp and backtick literals, comments, +/// `)` (#1509). A `source` call counts wherever it starts an expression, +/// as a keyword does (`x = 1; source "…" do`, `… then source "…" do`, +/// #1531). String, `%w[]`, regexp and backtick literals, comments, /// `=begin` … `=end` blocks and heredoc bodies are not code. fn block_stack(text: &str, at: usize) -> Vec { let mut open: Vec = Vec::new(); @@ -585,11 +587,12 @@ fn block_stack(text: &str, at: usize) -> Vec { let mut depth = 0usize; // Each open `{`: a hash (counted in `depth`) or a block (a frame). let mut braces: Vec = Vec::new(); - // The statement began with `source` and its block has not opened yet. - let mut source_pending = false; - // The statement began with `source(`: (its argument list is open, it - // has closed). - let mut source_call = (false, false); + // A `source` call whose block has not opened yet: the number of brace + // blocks open around it (a `}` closing one of them ends the call). + let mut source_pending: Option = None; + // The `source(` call's argument list: (it is open, it has closed or + // there is none). + let mut source_call = (false, true); let mut statement_line = 0; let mut start = 0; for raw in text[..at].split_inclusive('\n') { @@ -620,28 +623,25 @@ fn block_stack(text: &str, at: usize) -> Vec { // An open bracket carries only a `source` statement on to its // block opener: elsewhere a bracket the masker missed (one in an // interpolation) must not swallow the rest of the file. - let continues = literal.is_some() || !carried.is_empty() || (depth > 0 && source_pending); + let continues = + literal.is_some() || !carried.is_empty() || (depth > 0 && source_pending.is_some()); let masked = masked_ruby_code(line, &mut literal); heredocs.extend(heredoc_openers(line, &masked)); let masked = masked.trim(); - // Whether a statement opens with a `source` call, and how: sets - // `source_pending` and `source_call` for it. - let begin_statement = |statement: &str, pending: &mut bool, call: &mut (bool, bool)| { - let rest = statement.trim_start().strip_prefix("source").unwrap_or("-"); - *pending = rest.starts_with([' ', '\t', '(']); - *call = (false, !rest.starts_with('(')); - }; if !continues { depth = 0; statement_line = line_start; - begin_statement(masked, &mut source_pending, &mut source_call); + source_pending = None; + source_call = (false, true); } for (n, statement) in masked.split(';').enumerate() { let lead = if n == 0 { carried.as_str() } else { "" }; - // A statement after a top-level `;` starts afresh, and may be - // a `source` call of its own (`x = 1; source "…" do`, #1531). - if n > 0 && depth == 0 { - begin_statement(statement, &mut source_pending, &mut source_call); + // A top-level `;` starts a new statement, unless it sits in a + // brace block of the `source` call's own arguments. + if n > 0 && depth == 0 && source_pending.is_none_or(|level| braces.len() <= level) { + statement_line = line_start; + source_pending = None; + source_call = (false, true); } // A `while`/`until`/`for` takes its optional `do` as part of // the loop, not as a block of its own. @@ -657,6 +657,13 @@ fn block_stack(text: &str, at: usize) -> Vec { } let expression = || starts_expression(&format!("{lead} {}", &statement[..start])); let (opens, source) = match word { + "source" if depth == 0 && expression() => { + if let Some(parenthesized) = source_call_args(&statement[end..]) { + source_pending = Some(braces.len()); + source_call = (false, !parenthesized); + } + (false, false) + } "(" | "[" => { if depth == 0 && !source_call.1 { source_call.0 = true; @@ -682,16 +689,17 @@ fn block_stack(text: &str, at: usize) -> Vec { // the one right after `source(…)` is the source's. "{" => { braces.push(false); - ( - true, - closed_source_call && std::mem::take(&mut source_pending), - ) + (true, closed_source_call && source_pending.take().is_some()) } "}" => { match braces.pop() { Some(true) => depth = depth.saturating_sub(1), Some(false) => { open.pop(); + // A `source` call inside the block ends with it. + if source_pending.is_some_and(|level| braces.len() < level) { + source_pending = None; + } } None => {} } @@ -701,7 +709,7 @@ fn block_stack(text: &str, at: usize) -> Vec { let opens = !std::mem::take(&mut loop_do); ( opens, - opens && depth == 0 && std::mem::take(&mut source_pending), + opens && depth == 0 && source_pending.take().is_some(), ) } "if" | "unless" => (expression(), false), @@ -750,6 +758,17 @@ fn block_stack(text: &str, at: usize) -> Vec { open } +/// Whether `rest`, what follows the word `source` that starts an +/// expression, makes it a call with arguments, and whether those are in +/// parentheses (`source(…)`). `source = …` assigns a local variable. +fn source_call_args(rest: &str) -> Option { + if rest.starts_with('(') { + return Some(true); + } + let args = rest.strip_prefix([' ', '\t'])?.trim_start(); + (!args.is_empty() && !args.starts_with('=')).then_some(false) +} + /// [`ruby_words`] and the brackets (`(`, `)`, `[`, `]`, `{`, `}`) of a /// masked code line, in order, with their byte range. fn ruby_tokens(code: &str) -> impl Iterator { @@ -1897,6 +1916,12 @@ mod tests { // comes back between the opener and the declaration. "source \"https://rubygems.org\"\n\ngem \"puma\"\nsource(\n \"https://rubygems.org\"\n) do\n \ gem \"rails\", \"7.0.0\"\n gem \"rack\"\nend\n", + // #1531: an opener after a `;`, also on a statement's + // continuation line, whose own indent the declaration follows. + "source \"https://rubygems.org\"\n\ngem \"puma\"\nzz = 1; source \"https://rubygems.org\" do\n \ + gem \"rails\", \"7.0.0\"\nend\n", + "source \"https://rubygems.org\"\n\ngem \"puma\"\n zz = [1,\n2]; source \"https://rubygems.org\" do\n \ + gem \"rails\", \"7.0.0\"\nend\n", ] { let lock = format!( "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.0.0)\n \ @@ -1951,9 +1976,10 @@ mod tests { } #[test] - fn source_block_after_semicolon() { - // #1531: a `source` statement after a `;` on its line opens a - // source block just as a line-leading one does. + fn source_block_opener_mid_line() { + // #1531: a `source` call after a `;` on its line, or anywhere else + // it starts an expression, opens a source block just as a + // line-leading one does. let at = |text: &str| in_source_block(text, text.find("gem \"rails\"").unwrap()); for opener in [ "zz = 1; source \"https://x\" do", @@ -1964,6 +1990,11 @@ mod tests { "zz = 1; source(\n \"https://x\"\n) do", "zz = 1; source \"https://x\",\n type: :rubygems do", "git_source(:gh) { |r| \"https://github.com/#{r}.git\" }; source \"https://x\" do", + // Anywhere a `source` call starts an expression. + "x = source \"https://x\" do", + "if true then source \"https://x\" do", + "a = [1,\n 2]; source \"https://x\" do", + "source ENV.fetch(\"S\") { warn \"x\"; \"https://x\" } do", ] { let close = if opener.ends_with('{') { "}" } else { "end" }; let t = @@ -1974,12 +2005,19 @@ mod tests { ); assert!(!at(&t), "{t}"); } - // Only a statement that begins with `source` is one. + // Not a `source` call that starts an expression. for line in [ "zz = 1; group :t do", "zz = 1; x.source \"https://x\" do", "zz = 1; sources \"https://x\" do", "foo(1; source \"https://x\") do", + "zz = 1; source = mirrors.first do", + "source = mirrors.first do", + "source == x do", + // A `source` call inside a closed brace block does not own the + // `do` after it. + "urls.each { |u| log(u); source u }.then do", + "urls.each { |u| source u }.then do", ] { let t = format!("{line}\n gem \"rails\"\nend\n"); assert!(!at(&t), "{t}"); From 6fff5aeb8fca417669ba9e4b474bacbceba84191 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:50:45 +0000 Subject: [PATCH 4/4] Keep an outer gem source call over inner ones A `source` call or `do` block inside the brace block of a source call's own arguments (`source ENV.fetch("S") { source "y" } do`) took over the outer call, so its real `do` block was not seen as a source block and rollback could drop the gem's `!` pin. Only a block opened at the call's own brace level now belongs to it. Refs #1531 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/upstream/gem.rs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs index 70fb818ed..67d078cf2 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs @@ -588,7 +588,8 @@ fn block_stack(text: &str, at: usize) -> Vec { // Each open `{`: a hash (counted in `depth`) or a block (a frame). let mut braces: Vec = Vec::new(); // A `source` call whose block has not opened yet: the number of brace - // blocks open around it (a `}` closing one of them ends the call). + // blocks open around it (a `}` closing one of them ends the call, and + // only a `do` or `{` at that level is its block). let mut source_pending: Option = None; // The `source(` call's argument list: (it is open, it has closed or // there is none). @@ -657,7 +658,9 @@ fn block_stack(text: &str, at: usize) -> Vec { } let expression = || starts_expression(&format!("{lead} {}", &statement[..start])); let (opens, source) = match word { - "source" if depth == 0 && expression() => { + // One in a brace block of an outer call's arguments + // does not replace that call. + "source" if depth == 0 && source_pending.is_none() && expression() => { if let Some(parenthesized) = source_call_args(&statement[end..]) { source_pending = Some(braces.len()); source_call = (false, !parenthesized); @@ -689,7 +692,11 @@ fn block_stack(text: &str, at: usize) -> Vec { // the one right after `source(…)` is the source's. "{" => { braces.push(false); - (true, closed_source_call && source_pending.take().is_some()) + ( + true, + closed_source_call + && take_source(&mut source_pending, braces.len() - 1), + ) } "}" => { match braces.pop() { @@ -709,7 +716,7 @@ fn block_stack(text: &str, at: usize) -> Vec { let opens = !std::mem::take(&mut loop_do); ( opens, - opens && depth == 0 && source_pending.take().is_some(), + opens && depth == 0 && take_source(&mut source_pending, braces.len()), ) } "if" | "unless" => (expression(), false), @@ -758,6 +765,12 @@ fn block_stack(text: &str, at: usize) -> Vec { open } +/// Whether the pending `source` call (see [`block_stack`]) sits at brace +/// level `level`, in which case the block opening there is its block. +fn take_source(pending: &mut Option, level: usize) -> bool { + pending.take_if(|l| *l == level).is_some() +} + /// Whether `rest`, what follows the word `source` that starts an /// expression, makes it a call with arguments, and whether those are in /// parentheses (`source(…)`). `source = …` assigns a local variable. @@ -1995,6 +2008,10 @@ mod tests { "if true then source \"https://x\" do", "a = [1,\n 2]; source \"https://x\" do", "source ENV.fetch(\"S\") { warn \"x\"; \"https://x\" } do", + // A `source` call or a `do` block inside the arguments' own + // brace block leaves the outer call's `do` its block. + "source ENV.fetch(\"S\") { source \"https://y\" } do", + "source ENV.fetch(\"S\") { [1].each do |v| v end; \"https://x\" } do", ] { let close = if opener.ends_with('{') { "}" } else { "end" }; let t =