From 828141fa82642d70d6e516b429b78dd20e4bcb0c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:10:23 +0000 Subject: [PATCH 1/3] Start fix for #1685 Assisted-by: Claude Code:claude-opus-5-5 From 4c2beb0242ad48d3043e58382c26063b81410e4a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:10:23 +0000 Subject: [PATCH 2/3] Fix nightly gem unwind test on bundler < 2.2 The unconverged-supersede unwind test needs a superseding hosted re-scan that moves only the Gemfile block to the new patch. Bundler 1.17 to 2.1 write one merged GEM section, where discovery can't attribute the new pin, so that re-scan refuses it (redirect_unattributable) and writes nothing. The window the test covers never opens there, and its premise assert failed on both old-bundler nightly legs. On those eras the test now asserts the refusal and that both files stay on the earlier patch, then stops with a SKIP line. Bundler 2.2+ runs the full rollback and remove checks as before. Fixes #1685 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_gem_build.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 3d1e01b83..4aac0df26 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -2339,6 +2339,31 @@ async fn unwind_after_unconverged_supersede(command: &str) { assert_eq!(code, 0, "{command}: gen 3 scan: {out}\n{err}"); let gemfile_path = fx.proj.join(fx.gemfile_name); let lock_path = fx.proj.join(fx.lock_name); + // Bundler < 2.2 merges every rubygems source into one GEM section, so + // discovery can't attribute the new pin and the superseding scan + // refuses it, writing nothing. The unconverged window never opens. + if !fx.bundler.at_least(2, 2) { + assert!( + out.contains("\"errorCode\": \"redirect_unattributable\""), + "{command}: gen 3 scan on a merged GEM section must refuse:\n{out}" + ); + let gemfile = std::fs::read_to_string(&gemfile_path).unwrap(); + let lock = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + gemfile.contains(GEN2) && !gemfile.contains(GEN3), + "{command}: the refused scan leaves the Gemfile on gen 2:\n{gemfile}" + ); + assert!( + lock.contains(GEN2) && !lock.contains(GEN3), + "{command}: the refused scan leaves the lock on gen 2:\n{lock}" + ); + println!( + "SKIP e2e_redirect_gem_build (unconverged-supersede {command}): bundler {} \ + refuses the superseding scan on its merged GEM section", + fx.bundler.version + ); + return; + } // The unconverged window this test is about: Gemfile on gen 3, lock on // gen 2. let gemfile = std::fs::read_to_string(&gemfile_path).unwrap(); From bb18fa128b0edadff2f6188b5ab662ee3523171e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:15:56 +0000 Subject: [PATCH 3/3] Unwind gen 2 on old bundler instead of stopping Review follow-up for the bundler < 2.2 branch of the unconverged supersede test. It now parses the scan output and requires the skipped event for this gem's new patch, checks that the refused scan left the Gemfile and lock byte-identical, and then still runs rollback or remove on the converged gen 2 pair with the same cleanup and frozen-install checks, so old bundler legs keep covering the unwind. The refusal itself is tracked as #1688. Refs #1685 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_gem_build.rs | 67 ++++++++++--------- 1 file changed, 36 insertions(+), 31 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 4aac0df26..8903fbb99 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -2277,9 +2277,11 @@ async fn gem_hosted_superseding_patch_keeps_gem_sections_in_bundler_order() { /// the lock keeps pinning the old one until a `bundle install`. A /// `rollback` / `remove` in that window must undo the new block along with /// the lock, or the pair stays inconsistent (frozen installs fail) and the -/// next unfrozen install re-patches. +/// next unfrozen install re-patches. Bundler < 2.2 never opens that +/// window (its merged GEM section makes the superseding scan refuse), so +/// there the test asserts the refusal and unwinds the converged gen 2 pair. #[tokio::test(flavor = "multi_thread")] -#[ignore = "host capstone: shells out to a real ruby/gem/bundler; \ +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (unconverged window >= 2.2); \ the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] async fn gem_hosted_unwind_after_unconverged_supersede_undoes_the_new_block() { for command in ["rollback", "remove"] { @@ -2329,6 +2331,10 @@ async fn unwind_after_unconverged_supersede(command: &str) { "{command}: converge gen 2:\n{}", String::from_utf8_lossy(&install.stderr) ); + let gemfile_path = fx.proj.join(fx.gemfile_name); + let lock_path = fx.proj.join(fx.lock_name); + let gen2_gemfile = std::fs::read_to_string(&gemfile_path).unwrap(); + let gen2_lock = std::fs::read_to_string(&lock_path).unwrap(); mount_patch_generation( &fx._server, 1, @@ -2337,42 +2343,41 @@ async fn unwind_after_unconverged_supersede(command: &str) { .await; let (code, out, err) = run_hosted_scan(&fx.proj, &api); assert_eq!(code, 0, "{command}: gen 3 scan: {out}\n{err}"); - let gemfile_path = fx.proj.join(fx.gemfile_name); - let lock_path = fx.proj.join(fx.lock_name); - // Bundler < 2.2 merges every rubygems source into one GEM section, so - // discovery can't attribute the new pin and the superseding scan - // refuses it, writing nothing. The unconverged window never opens. - if !fx.bundler.at_least(2, 2) { + let gemfile = std::fs::read_to_string(&gemfile_path).unwrap(); + let lock = std::fs::read_to_string(&lock_path).unwrap(); + if fx.bundler.at_least(2, 2) { + // The unconverged window this test is about: Gemfile on gen 3, + // lock on gen 2. + assert!(gemfile.contains(GEN3), "{command}: Gemfile:\n{gemfile}"); assert!( - out.contains("\"errorCode\": \"redirect_unattributable\""), - "{command}: gen 3 scan on a merged GEM section must refuse:\n{out}" + lock.contains(GEN2) && !lock.contains(GEN3), + "{command}: lock:\n{lock}" ); - let gemfile = std::fs::read_to_string(&gemfile_path).unwrap(); - let lock = std::fs::read_to_string(&lock_path).unwrap(); + } else { + // Bundler < 2.2 merges every rubygems source into one GEM section, + // where discovery can't attribute the new pin, so the superseding + // scan refuses it and writes nothing (#1688). The window never + // opens; the unwind below then undoes the converged gen 2 pair. + let env: serde_json::Value = serde_json::from_str(&out) + .unwrap_or_else(|e| panic!("{command}: gen 3 scan JSON ({e}):\n{out}")); assert!( - gemfile.contains(GEN2) && !gemfile.contains(GEN3), - "{command}: the refused scan leaves the Gemfile on gen 2:\n{gemfile}" + env["events"].as_array().into_iter().flatten().any(|e| { + e["action"] == "skipped" + && e["purl"] == PURL + && e["uuid"] == GEN3 + && e["errorCode"] == "redirect_unattributable" + }), + "{command}: gen 3 scan on a merged GEM section must refuse:\n{out}" ); - assert!( - lock.contains(GEN2) && !lock.contains(GEN3), - "{command}: the refused scan leaves the lock on gen 2:\n{lock}" + assert_eq!( + gemfile, gen2_gemfile, + "{command}: the refused scan wrote the Gemfile" ); - println!( - "SKIP e2e_redirect_gem_build (unconverged-supersede {command}): bundler {} \ - refuses the superseding scan on its merged GEM section", - fx.bundler.version + assert_eq!( + lock, gen2_lock, + "{command}: the refused scan wrote the lock" ); - return; } - // The unconverged window this test is about: Gemfile on gen 3, lock on - // gen 2. - let gemfile = std::fs::read_to_string(&gemfile_path).unwrap(); - let lock = std::fs::read_to_string(&lock_path).unwrap(); - assert!(gemfile.contains(GEN3), "{command}: Gemfile:\n{gemfile}"); - assert!( - lock.contains(GEN2) && !lock.contains(GEN3), - "{command}: lock:\n{lock}" - ); let upstream = format!("{api}/upstream"); let cwd = fx.proj.to_str().expect("utf8 tmp path");