From ac0a657037f39213713acd9293fd1293ee90434f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:30:10 +0000 Subject: [PATCH 01/15] Start fix for #806, #821 Assisted-by: Claude Code:claude-opus-5-5 From c35bf96997384a77000229dd0cf2e47a7eb9133b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:47:22 +0000 Subject: [PATCH 02/15] Unwind uv vendoring after a relock After vendoring, an ordinary uv relock (`uv add --dev x`, `uv add y`) re-serializes the lock arrays that hold our element: the dev group's requires-dev line and `[manifest] overrides`. Revert matched those arrays by their exact recorded text, so it saw drift and kept uv.lock wired, but still reverted pyproject.toml. The pair then failed `uv sync --locked` while `vendor --revert` reported success. Revert now finds our unchanged element inside the live array under the same key and restores or removes just that element, rendering the array the way uv writes it. A pair gate also writes neither file when any record is genuinely drift-kept, so pyproject.toml and uv.lock always stay consistent. Fixes #806, #821. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/vendor/pypi_uv.rs | 551 ++++++++++++++++-- 1 file changed, 507 insertions(+), 44 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 3b602ba6b..b201f6461 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -843,7 +843,21 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - if original_text.is_some_and(|orig| lock_text.contains(orig)) { continue; } - warnings.push(drifted("uv.lock")); + // A whole-array record whose array uv re-serialized + // around our unchanged element (`uv add --dev x` + // rewrites the group line, #821): revert just our + // element inside the live array. + match revert_array_elements( + &lock_text, + &rec.kind, + new_text, + original_text, + &needle, + ) { + ArrayRevert::Reverted(t) => lock_text = t, + ArrayRevert::Converged => {} + ArrayRevert::Drift => warnings.push(drifted("uv.lock")), + } } } } @@ -886,7 +900,21 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - if original_text.is_some_and(|orig| lock_text.contains(orig)) { continue; } - warnings.push(drifted("uv.lock")); + // uv re-serializes `[manifest] overrides` sorted + // and multi-line on any relock that touches it + // (#806): remove just our element from the live + // array. + match revert_array_elements( + &lock_text, + &rec.kind, + new_text, + original_text, + &needle, + ) { + ArrayRevert::Reverted(t) => lock_text = t, + ArrayRevert::Converged => {} + ArrayRevert::Drift => warnings.push(drifted("uv.lock")), + } } } } @@ -965,7 +993,16 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - } } - if !dry_run { + // PAIR GATE (docs/testing/uv-compatibility.md: "conflicting changes + // preserve both files"): pyproject.toml and uv.lock are one unit. When + // any record was drift-kept, restoring the rest would leave one file + // wired and the other not, which `uv sync --locked` rejects (#806, + // #821). Write neither; the drift warning keeps the artifact and ledger + // entry so a re-run can finish once the drift is undone. + let drift_kept = warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"); + if !dry_run && !drift_kept { // Reverse of the wire order: the lock first, then the pyproject. let write = atomic_write_bytes_preserving_mode(&lock_path, lock_text.as_bytes()).await; LOCK_MEMO.invalidate(); @@ -999,6 +1036,174 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // ── helpers ────────────────────────────────────────────────────────────── +/// Result of [`revert_array_elements`]. +enum ArrayRevert { + /// Our elements were restored or removed; the new lock text. + Reverted(String), + /// Nothing of ours is left in the array: the reverted state already + /// holds (the LIVENESS CONTRACT; silent). + Converged, + /// Our element is gone but the array still routes through the + /// artifact, or the record can't be read: genuine drift. + Drift, +} + +/// Where a whole-array lock record lives. +#[derive(Clone, Copy)] +enum ArrayScope { + /// The root unit's `[package.metadata.requires-dev]` sub-table. + RootRequiresDev, + /// The top-level `[manifest]` table. + Manifest, +} + +/// Element-level revert of a record that captured a WHOLE lock array +/// (`uv_lock_requires_dev` / `uv_lock_manifest_constraints` as a ` = +/// […]` line, `uv_lock_manifest_overrides` Rewritten as the bare array). +/// uv re-serializes those arrays on any relock that touches a sibling +/// element (`uv add --dev x`, `uv add y` sorting `[manifest] overrides` +/// into its multi-line form), so the recorded text stops matching while +/// our element is byte-identical inside. Diff the recorded old/new arrays +/// into our element edits (an element rewritten in place, or one appended), +/// apply them to the live array under the same key, and re-render the +/// array the way uv writes it: one element inline, more one per line. +fn revert_array_elements( + lock_text: &str, + kind: &str, + new: Option<&str>, + orig: Option<&str>, + needle: &str, +) -> ArrayRevert { + let (Some(new), Some(orig)) = (new, orig) else { + return ArrayRevert::Drift; + }; + let (scope, key, old_array, new_array) = match kind { + "uv_lock_requires_dev" | "uv_lock_manifest_constraints" => { + let (Some((key, old_arr)), Some((new_key, new_arr))) = + (orig.split_once(" = "), new.split_once(" = ")) + else { + return ArrayRevert::Drift; + }; + if key != new_key { + return ArrayRevert::Drift; + } + let scope = if kind == "uv_lock_requires_dev" { + ArrayScope::RootRequiresDev + } else { + ArrayScope::Manifest + }; + (scope, key, old_arr, new_arr) + } + "uv_lock_manifest_overrides" => (ArrayScope::Manifest, "overrides", orig, new), + _ => return ArrayRevert::Drift, + }; + let elements = |arr: &str| -> Vec { + top_level_brace_groups(arr) + .into_iter() + .map(|(s, e)| arr[s..e].to_string()) + .collect() + }; + let (old_els, new_els) = (elements(old_array), elements(new_array)); + // (ours, Some(original)) = rewritten in place; (ours, None) = appended. + let mut edits: Vec<(String, Option)> = Vec::new(); + if old_els.len() == new_els.len() { + for (o, n) in old_els.iter().zip(&new_els) { + if o != n { + edits.push((n.clone(), Some(o.clone()))); + } + } + } else if new_els.len() == old_els.len() + 1 && new_els[..old_els.len()] == old_els[..] { + edits.push((new_els[old_els.len()].clone(), None)); + } + if edits.is_empty() || edits.iter().any(|(ours, _)| !ours.contains(needle)) { + return ArrayRevert::Drift; + } + + let Some(span) = locate_lock_array(lock_text, scope, key) else { + // The whole key is gone (uv drops an emptied group): nothing in it + // routes through the artifact. + return ArrayRevert::Converged; + }; + let mut live = elements(&lock_text[span.clone()]); + let mut changed = false; + for (ours, original) in &edits { + match live.iter().position(|el| el == ours) { + Some(i) => { + match original { + Some(o) => live[i] = o.clone(), + None => { + live.remove(i); + } + } + changed = true; + } + // Our element was edited in place and still routes through the + // artifact: drift, fail-closed. + None if live.iter().any(|el| el.contains(needle)) => return ArrayRevert::Drift, + // Our element is gone and nothing else in the array routes + // through the artifact: that element's reverted state holds. + None => {} + } + } + if !changed { + return ArrayRevert::Converged; + } + let nl = newline_of(lock_text); + let rendered = match live.len() { + 0 => "[]".to_string(), + 1 => format!("[{}]", live[0]), + _ => { + let mut out = format!("[{nl}"); + for el in &live { + out.push_str(&format!(" {el},{nl}")); + } + out.push(']'); + out + } + }; + let mut text = lock_text.to_string(); + text.replace_range(span, &rendered); + ArrayRevert::Reverted(text) +} + +/// Byte span of the `[…]` array assigned to `key` at line start inside +/// `scope`, or `None` when the section or key is absent. +fn locate_lock_array(lock_text: &str, scope: ArrayScope, key: &str) -> Option> { + let section = match scope { + ArrayScope::RootRequiresDev => { + let unit = find_unit_span(lock_text, unit_is_root)?; + let header = "[package.metadata.requires-dev]"; + let hdr = unit.start + lock_text[unit.clone()].find(header)?; + let start = hdr + header.len(); + // Elements are indented, so a line-leading `[` is the next + // sub-table header. + let end = lock_text[start..unit.end] + .find("\n[") + .map_or(unit.end, |i| start + i + 1); + start..end + } + ArrayScope::Manifest => { + let index = line_index(lock_text); + let h = index + .iter() + .position(|(_, l)| l.trim_end() == "[manifest]")?; + let end = index[h + 1..] + .iter() + .find(|(_, l)| l.starts_with('[')) + .map_or(lock_text.len(), |(off, _)| *off); + index[h].0..end + } + }; + let prefix = format!("{key} = ["); + let line_off = line_index(&lock_text[section.clone()]) + .into_iter() + .find(|(_, l)| l.starts_with(&prefix)) + .map(|(off, _)| section.start + off)?; + let open = line_off + prefix.len() - 1; + let end = balanced_span(lock_text, open)?; + Some(open..end) +} + /// The two files this backend edits — both are checked for symlinks before /// any write (uv itself writes through a link; the atomic rename would /// replace it) and re-verified against the pre-flight snapshot. @@ -2634,6 +2839,7 @@ wheels = [ tokio::fs::write(tmp.path().join("uv.lock"), &drifted) .await .unwrap(); + let (wired_py, _) = read_pair(tmp.path()).await; let outcome = revert_uv(&entry, tmp.path(), false).await; assert!(outcome.success); @@ -2645,9 +2851,11 @@ wheels = [ "{:?}", outcome.warnings ); - // The pyproject side (undrifted) was still reverted. - let (pyproject, _) = read_pair(tmp.path()).await; - assert_eq!(pyproject, DIRECT_REGISTRY_PYPROJECT); + // PAIR GATE: the undrifted pyproject side is NOT reverted alone — + // that would leave a pair `uv sync --locked` rejects. + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, wired_py); + assert_eq!(lock, drifted); } /// mkfifo(2) directly rather than shelling out to the `mkfifo` binary — @@ -3743,11 +3951,12 @@ wheels = [ assert_eq!(lock, input_lock, "only our added line may be removed"); } - /// A third-party edit to the REWRITTEN `[manifest] overrides` array must - /// be left alone with a drift warning — the never-clobber contract for - /// this record kind. + /// A third-party edit to the REWRITTEN `[manifest] overrides` array + /// around our unchanged element (a sibling replaced, ours moved) is not + /// drift of OUR element: revert removes just our element and keeps the + /// user's reshaping (#806). #[tokio::test] - async fn revert_warns_and_skips_on_drifted_manifest_overrides_array() { + async fn revert_removes_our_element_from_a_reshaped_manifest_overrides_array() { let one_el = "overrides = [{ name = \"other\", path = \"o.whl\" }]"; let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( "requires-python = \">=3.10\"\n", @@ -3789,24 +3998,16 @@ wheels = [ let entry = entry_for(wiring, meta); let outcome = revert_uv(&entry, tmp.path(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert_eq!(outcome.warnings.len(), 1, "{:?}", outcome.warnings); - assert_eq!(outcome.warnings[0].code, "vendor_lock_entry_drifted"); - assert!( - outcome.warnings[0].detail.contains("uv.lock"), - "{}", - outcome.warnings[0].detail - ); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); let (py, lock) = read_pair(tmp.path()).await; assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT); let expected = input_lock.replace( "[{ name = \"other\", path = \"o.whl\" }]", - &format!( - "[{{ name = \"six\", path = \"{REL_WHEEL}\" }}, {{ name = \"extra\", path = \"e.whl\" }}]" - ), + "[{ name = \"extra\", path = \"e.whl\" }]", ); assert_eq!( lock, expected, - "the undrifted [[package]] fragment still reverts; the array is left as the user edited it" + "our element is removed; the user's reshaping of the array stays" ); } @@ -3854,13 +4055,12 @@ wheels = [ outcome.warnings[0].detail ); let (py, lock) = read_pair(tmp.path()).await; - assert_eq!(lock, DIRECT_REGISTRY_LOCK, "the lock side still reverts"); - assert!( - py.contains(&format!( - "six = {{ path = \"{REL_WHEEL}\", editable = false }}" - )), - "the drifted pyproject is left alone: {py}" + assert_eq!(py, tampered, "the drifted pyproject is left alone"); + assert_ne!( + lock, DIRECT_REGISTRY_LOCK, + "PAIR GATE: the lock stays wired with its drift-kept pyproject" ); + assert!(lock.contains(REL_WHEEL), "{lock}"); } /// LIVENESS CONTRACT (vendor/mod.rs): a hand-restored pair — the user @@ -4068,14 +4268,10 @@ wheels = [ outcome.warnings[0].detail ); let (py, lock) = read_pair(tmp.path()).await; - assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK); - assert!( - py.contains("override-dependencies = [\"six==1.17.0\"]"), - "the user's edit must survive: {py}" - ); + assert_eq!(py, tampered, "the user's edit must survive, untouched"); assert!( - !py.contains("[tool.uv.sources]"), - "the undrifted sources entry (and its created table) still reverts: {py}" + lock.contains(REL_WHEEL), + "PAIR GATE: the lock stays wired with its drift-kept pyproject: {lock}" ); } @@ -4741,12 +4937,12 @@ wheels = [ outcome.warnings[0].detail ); let (py, lock) = read_pair(tmp.path()).await; - assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT); - let expected = tampered.replacen(&pkg_new, &pkg_orig, 1); assert_eq!( - lock, expected, - "the [[package]] fragment reverts; the reshaped [manifest] stays" + py, OVERRIDE_TRANSITIVE_PYPROJECT, + "PAIR GATE: the pyproject stays wired with the drift-kept lock" ); + assert_eq!(lock, tampered, "the lock is left exactly as found"); + assert!(tampered.contains(&pkg_new) && !tampered.contains(&pkg_orig)); } /// Hand-restoring a pair whose lock had a PRE-EXISTING overrides array @@ -4867,14 +5063,13 @@ wheels = [ outcome.warnings[0].detail ); let (py, lock) = read_pair(tmp.path()).await; - assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK, "the lock still reverts"); - assert!( - py.contains("override-dependencies = [\"attrs==23.9.9\", \"six==1.16.0\"]"), - "the drifted array is left as the user edited it: {py}" + assert_eq!( + py, tampered, + "the drifted pyproject is left as the user edited it" ); assert!( - !py.contains("[tool.uv.sources]"), - "the undrifted sources entry still reverts: {py}" + lock.contains(REL_WHEEL), + "PAIR GATE: the lock stays wired with its drift-kept pyproject: {lock}" ); } @@ -5907,4 +6102,272 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 assert_sources_spelling_round_trips(&pyproject, DIRECT_REGISTRY_LOCK, &six_source_line("")) .await; } + + // ── relock re-serialization (#806, #821) + the pair gate ────────────── + + /// #821: six is a dev-group dependency and the user runs `uv add --dev + /// zipp` after vendoring. uv re-serializes the WHOLE `dev = […]` + /// requires-dev line (multi-line, sorted) while six's element stays + /// byte-identical. Revert must recognise our element inside the + /// re-serialized group, restore just that element, and unwind BOTH + /// files — no drift, no half-revert. + #[tokio::test] + async fn revert_survives_uv_reserializing_the_dev_group_line() { + let tmp = write_pair(DEV_GROUP_REGISTRY_PYPROJECT, DEV_GROUP_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + + // What `uv add --dev zipp` does to the pair. + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let relocked_lock = wired_lock.replace( + &format!("dev = [{six_el}]"), + &format!("dev = [\n {six_el},\n {{ name = \"zipp\", specifier = \">=3\" }},\n]"), + ); + assert_ne!(relocked_lock, wired_lock, "the relock must hit the group"); + let relocked_py = wired_py.replace( + "dev = [\"six==1.16.0\"]", + "dev = [\"six==1.16.0\", \"zipp>=3\"]", + ); + tokio::fs::write(tmp.path().join("uv.lock"), &relocked_lock) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("pyproject.toml"), &relocked_py) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(!outcome.drift_skipped()); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!( + py, + DEV_GROUP_REGISTRY_PYPROJECT.replace( + "dev = [\"six==1.16.0\"]", + "dev = [\"six==1.16.0\", \"zipp>=3\"]", + ) + ); + assert_eq!( + lock, + DEV_GROUP_REGISTRY_LOCK.replace( + "dev = [{ name = \"six\", specifier = \"==1.16.0\" }]", + "dev = [\n { name = \"six\", specifier = \"==1.16.0\" },\n { name = \"zipp\", specifier = \">=3\" },\n]", + ), + "only six's element is restored; the user's zipp stays" + ); + assert!(!lock.contains(".socket/vendor"), "{lock}"); + } + + /// #821 (`uv remove --dev`): a sibling leaving the group collapses uv's + /// multi-line array back to one inline element — revert still finds and + /// restores our element. + #[tokio::test] + async fn revert_survives_a_sibling_leaving_the_dev_group() { + let two = "dev = [\n { name = \"attrs\", specifier = \">=20\" },\n { name = \"six\", specifier = \"==1.16.0\" },\n]"; + let input_lock = DEV_GROUP_REGISTRY_LOCK + .replace("dev = [{ name = \"six\", specifier = \"==1.16.0\" }]", two); + let input_py = DEV_GROUP_REGISTRY_PYPROJECT.replace( + "dev = [\"six==1.16.0\"]", + "dev = [\"six==1.16.0\", \"attrs>=20\"]", + ); + let tmp = write_pair(&input_py, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let wired_group = + format!("dev = [\n {{ name = \"attrs\", specifier = \">=20\" }},\n {six_el},\n]"); + assert!(wired_lock.contains(&wired_group), "{wired_lock}"); + // `uv remove --dev attrs`. + tokio::fs::write( + tmp.path().join("uv.lock"), + wired_lock.replace(&wired_group, &format!("dev = [{six_el}]")), + ) + .await + .unwrap(); + tokio::fs::write( + tmp.path().join("pyproject.toml"), + wired_py.replace(", \"attrs>=20\"", ""), + ) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, DEV_GROUP_REGISTRY_PYPROJECT); + assert_eq!(lock, DEV_GROUP_REGISTRY_LOCK); + } + + /// #806: the user already authors `override-dependencies`, six arrives + /// transitively, and a later relock (`uv add idna`) re-serializes + /// `[manifest] overrides` sorted and multi-line. Our element is + /// unchanged inside it, so revert must remove just that element and + /// unwind BOTH files. + #[tokio::test] + async fn revert_survives_uv_reserializing_manifest_overrides() { + let user_py = format!( + "{TRANSITIVE_REGISTRY_PYPROJECT}\n[tool.uv]\noverride-dependencies = [\"attrs>=20\"]\n" + ); + let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + "requires-python = \">=3.10\"\n\n[manifest]\noverrides = [{ name = \"attrs\", specifier = \">=20\" }]\n", + ); + let tmp = write_pair(&user_py, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + assert_eq!(classify_dependency(&p, "six"), UvDepClass::Transitive); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + + let (_, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let ours = format!("overrides = [{{ name = \"attrs\", specifier = \">=20\" }}, {six_el}]"); + assert!(wired_lock.contains(&ours), "{wired_lock}"); + // uv's own spelling after any relock that rewrites the array. + let uv_spelling = format!( + "overrides = [\n {{ name = \"attrs\", specifier = \">=20\" }},\n {six_el},\n]" + ); + tokio::fs::write( + tmp.path().join("uv.lock"), + wired_lock.replace(&ours, &uv_spelling), + ) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(!outcome.drift_skipped()); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, user_py); + assert_eq!( + lock, input_lock, + "one remaining element renders inline, as uv writes it" + ); + } + + /// #806 with a second user override sorted AFTER ours: removing our + /// element from uv's multi-line array keeps uv's multi-line shape. + #[tokio::test] + async fn revert_removes_our_override_from_a_sorted_multi_line_array() { + let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + "requires-python = \">=3.10\"\n\n[manifest]\noverrides = [\n { name = \"attrs\", specifier = \">=20\" },\n { name = \"zipp\", specifier = \">=3\" },\n]\n", + ); + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (_, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let ours = format!( + "overrides = [\n {{ name = \"attrs\", specifier = \">=20\" }},\n {{ name = \"zipp\", specifier = \">=3\" }},\n {six_el},\n]" + ); + assert!(wired_lock.contains(&ours), "{wired_lock}"); + let sorted = format!( + "overrides = [\n {{ name = \"attrs\", specifier = \">=20\" }},\n {six_el},\n {{ name = \"zipp\", specifier = \">=3\" }},\n]" + ); + tokio::fs::write( + tmp.path().join("uv.lock"), + wired_lock.replace(&ours, &sorted), + ) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT); + assert_eq!(lock, input_lock); + } + + /// The pair gate: when a uv.lock record is GENUINELY drift-kept (our + /// element itself was edited and still routes through the artifact), + /// revert must write NEITHER file — restoring pyproject.toml alone would + /// leave a pair `uv sync --locked` rejects (#806, #821). + #[tokio::test] + async fn revert_writes_neither_file_when_a_lock_record_is_drift_kept() { + let tmp = write_pair(DEV_GROUP_REGISTRY_PYPROJECT, DEV_GROUP_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + // Our own element was edited (a marker added) — genuine drift. + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let edited = format!( + "{{ name = \"six\", marker = \"python_full_version >= '3.11'\", path = \"{REL_WHEEL}\" }}" + ); + let drifted_lock = wired_lock.replace(&six_el, &edited); + assert_ne!(drifted_lock, wired_lock); + tokio::fs::write(tmp.path().join("uv.lock"), &drifted_lock) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.drift_skipped(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, wired_py, "pyproject.toml must stay wired with its lock"); + assert_eq!(lock, drifted_lock, "uv.lock is left exactly as found"); + } } From bb174d245747819b7a8769595ca3efb369d8192f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:47:22 +0000 Subject: [PATCH 03/15] Test uv revert after a relock with real uv Vendor six, run the uv command that re-serializes the lock array around our element (`uv add --dev zipp` for a dev group, `uv add idna` beside user overrides), then revert. Both files must be unwired with no drift warning, and `uv lock --check` must pass. Refs #806, #821. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_pypi_build.rs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index c3b431d85..c0da761ad 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -946,6 +946,125 @@ fn uv_vendor_revert_sub_table_sources() { ); } +/// Vendor six on a REAL uv project, run `relock` (a uv command the user +/// runs after vendoring that re-serializes a lock array around our +/// element), then `vendor --revert`. The revert must recognise our element +/// inside uv's re-serialized array and unwind BOTH files: no drift, nothing +/// left routing through `.socket/vendor`, and `uv lock --check` accepts +/// the pair (#806, #821). Before the fix the pyproject side reverted alone +/// and `uv sync --locked` failed while revert reported success. +fn uv_relock_then_revert(tag: &str, pyproject: &str, relock: &[&str]) { + let Some((uv, python)) = capstone_uv(tag) else { + return; + }; + bake_leak_guards(); + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let cache = tmp.path().join("uv-cache"); + let mut cache_env: Vec<(&str, &str)> = vec![("UV_CACHE_DIR", cache.to_str().unwrap())]; + if let Some(py) = python.as_deref() { + cache_env.push(("UV_PYTHON", py)); + } + std::fs::write(proj.join("pyproject.toml"), pyproject).unwrap(); + for step in [&["lock", "-q"][..], &["sync", "-q"][..]] { + let out = tool(&uv, &proj, step, &cache_env); + if !out.status.success() { + println!( + "SKIP e2e_vendor_pypi_build({tag}): `uv {}` failed (PyPI unreachable?):\n{}", + step[0], + String::from_utf8_lossy(&out.stderr) + ); + return; + } + } + let installed_six = site_packages(&proj.join(".venv")).join("six.py"); + stage_patch(&proj, &installed_six); + + let (code, stdout, stderr) = run_vendored(&VendorDriver::VendorOffline, &proj); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_vendored_applied(&parse_envelope(&stdout)); + let check = tool(&uv, &proj, &["lock", "--check"], &cache_env); + assert_tool_ok(&check, "`uv lock --check` on the wired pair"); + + let out = tool(&uv, &proj, relock, &cache_env); + if !out.status.success() { + println!( + "SKIP e2e_vendor_pypi_build({tag}): `uv {}` failed (PyPI unreachable?):\n{}", + relock.join(" "), + String::from_utf8_lossy(&out.stderr) + ); + return; + } + let relocked = std::fs::read_to_string(proj.join("uv.lock")).unwrap(); + assert!( + relocked.contains(".socket/vendor/pypi/"), + "the relock must keep six vendored: {relocked}" + ); + + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert!( + !stdout.contains("vendor_lock_entry_drifted"), + "uv re-serializing the array around our element is not drift: {renv}" + ); + for file in ["pyproject.toml", "uv.lock"] { + let text = std::fs::read_to_string(proj.join(file)).unwrap(); + assert!( + !text.contains(".socket/vendor"), + "{file} must be fully unwired:\n{text}" + ); + } + let check = tool(&uv, &proj, &["lock", "--check"], &cache_env); + assert_tool_ok(&check, "`uv lock --check` after the revert"); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); +} + +/// #821: six in a PEP 735 dev group, then `uv add --dev zipp` rewrites the +/// whole `requires-dev` group line. +#[test] +#[serial_test::serial] +fn uv_vendor_revert_after_dev_group_relock() { + uv_relock_then_revert( + "uv-dev-group-relock", + "[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = []\n\n[dependency-groups]\ndev = [\"six==1.16.0\", \"attrs>=20\"]\n", + &["add", "-q", "--dev", "zipp"], + ); +} + +/// #806: six arrives transitively next to a user-authored +/// `override-dependencies`, then `uv add idna` re-serializes `[manifest] +/// overrides` sorted and multi-line. +#[test] +#[serial_test::serial] +fn uv_vendor_revert_after_manifest_overrides_relock() { + uv_relock_then_revert( + "uv-overrides-relock", + "[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"python-dateutil==2.9.0.post0\"]\n\n[tool.uv]\nconstraint-dependencies = [\"six==1.16.0\"]\noverride-dependencies = [\"attrs>=20\"]\n", + &["add", "-q", "idna==3.7"], + ); +} + /// `get --mode vendored` twin of the uv capstone above (v3.6): the /// SAME vendor engine and wiring, driven through get's uuid path — exempt /// from installed narrowing, so only the mocked `view/{uuid}` route is From f4e9b7b50d375427a7fc39170b7027f076dc1507 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:47:44 +0000 Subject: [PATCH 04/15] Document uv revert after a relock Refs #806, #821. Assisted-by: Claude Code:claude-opus-5-5 --- docs/testing/uv-compatibility.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 42d248742..427a1367c 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -175,7 +175,11 @@ frozen, locked, and ordinary installation outcomes separately where supported. Revert state retains the original wiring. Script and lock edits are treated as a pair: conflicting changes preserve both files and their recovery state rather -than restoring only one side. Tests also cover restoring one package while +than restoring only one side. When any uv.lock or pyproject.toml record has +drifted, neither file is written. A relock that only re-serializes an array +around socket-patch's unchanged element is not drift: `uv add --dev x` rewrites +the dev group's `requires-dev` line, and `uv add y` sorts `[manifest] overrides` +into its multi-line form. Revert restores or removes just that element. Tests also cover restoring one package while preserving another package's vendored entries. ## Reproduce the release-family matrix From f9b7cfae4fdd3f6a97efba0c1fd93b9a6d15acfa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:10:59 +0000 Subject: [PATCH 05/15] Anchor uv array reverts on their key A [manifest] overrides record holds the bare array, and the old convergence shortcut searched the whole lock for it. When the root requires-dist happened to match the user's overrides array, revert treated our element as already gone, left it in uv.lock and deleted the artifact it points at. Every whole-array record now reverts through its own key: an untouched array is restored verbatim, otherwise just our element is. Refs #806. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/vendor/pypi_uv.rs | 144 ++++++++++++------ 1 file changed, 96 insertions(+), 48 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index b201f6461..9df6393f9 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -826,10 +826,20 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - ) }; match rec.kind.as_str() { - "uv_lock_package" - | "uv_lock_requires_dist" - | "uv_lock_requires_dev" - | "uv_lock_manifest_constraints" => { + // Whole-array records are anchored on their key, never matched + // by text anywhere in the lock: a byte-identical array under + // another key (`build-constraints` holds `constraints = [`, a + // root `requires-dist` can equal the user's overrides) would + // otherwise splice the wrong array or fake convergence. + "uv_lock_requires_dev" | "uv_lock_manifest_constraints" => { + match revert_array_elements(&lock_text, &rec.kind, new_text, original_text, &needle) + { + ArrayRevert::Reverted(t) => lock_text = t, + ArrayRevert::Converged => {} + ArrayRevert::Drift => warnings.push(drifted("uv.lock")), + } + } + "uv_lock_package" | "uv_lock_requires_dist" => { match replace_fragment(&lock_text, new_text, original_text) { Some(t) => lock_text = t, None => { @@ -843,21 +853,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - if original_text.is_some_and(|orig| lock_text.contains(orig)) { continue; } - // A whole-array record whose array uv re-serialized - // around our unchanged element (`uv add --dev x` - // rewrites the group line, #821): revert just our - // element inside the live array. - match revert_array_elements( - &lock_text, - &rec.kind, - new_text, - original_text, - &needle, - ) { - ArrayRevert::Reverted(t) => lock_text = t, - ArrayRevert::Converged => {} - ArrayRevert::Drift => warnings.push(drifted("uv.lock")), - } + warnings.push(drifted("uv.lock")); } } } @@ -892,32 +888,21 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - } } } - WiringAction::Rewritten => { - match replace_fragment(&lock_text, new_text, original_text) { - Some(t) => lock_text = t, - None => { - // ALREADY CONVERGED: see the package-unit arm. - if original_text.is_some_and(|orig| lock_text.contains(orig)) { - continue; - } - // uv re-serializes `[manifest] overrides` sorted - // and multi-line on any relock that touches it - // (#806): remove just our element from the live - // array. - match revert_array_elements( - &lock_text, - &rec.kind, - new_text, - original_text, - &needle, - ) { - ArrayRevert::Reverted(t) => lock_text = t, - ArrayRevert::Converged => {} - ArrayRevert::Drift => warnings.push(drifted("uv.lock")), - } - } - } - } + // The record holds the bare array, so only the `overrides` + // key can anchor it (see the whole-array arm above). uv + // re-serializes it sorted and multi-line on any relock that + // touches it (#806). + WiringAction::Rewritten => match revert_array_elements( + &lock_text, + &rec.kind, + new_text, + original_text, + &needle, + ) { + ArrayRevert::Reverted(t) => lock_text = t, + ArrayRevert::Converged => {} + ArrayRevert::Drift => warnings.push(drifted("uv.lock")), + }, }, "uv_sources_entry" => { let Some(new) = new_text else { @@ -1057,14 +1042,15 @@ enum ArrayScope { Manifest, } -/// Element-level revert of a record that captured a WHOLE lock array +/// Key-anchored revert of a record that captured a WHOLE lock array /// (`uv_lock_requires_dev` / `uv_lock_manifest_constraints` as a ` = /// […]` line, `uv_lock_manifest_overrides` Rewritten as the bare array). /// uv re-serializes those arrays on any relock that touches a sibling /// element (`uv add --dev x`, `uv add y` sorting `[manifest] overrides` /// into its multi-line form), so the recorded text stops matching while -/// our element is byte-identical inside. Diff the recorded old/new arrays -/// into our element edits (an element rewritten in place, or one appended), +/// our element is byte-identical inside. An array still holding exactly the +/// recorded text is restored verbatim; otherwise diff the recorded old/new +/// arrays into our element edits (an element rewritten in place, or one appended), /// apply them to the live array under the same key, and re-render the /// array the way uv writes it: one element inline, more one per line. fn revert_array_elements( @@ -1124,6 +1110,12 @@ fn revert_array_elements( // routes through the artifact. return ArrayRevert::Converged; }; + if lock_text[span.clone()] == *new_array { + // Untouched since vendoring: restore the recorded original verbatim. + let mut text = lock_text.to_string(); + text.replace_range(span, old_array); + return ArrayRevert::Reverted(text); + } let mut live = elements(&lock_text[span.clone()]); let mut changed = false; for (ours, original) in &edits { @@ -6330,6 +6322,62 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 assert_eq!(lock, input_lock); } + /// The user's `[manifest] overrides` array is byte-identical to another + /// array in the lock (the root `requires-dist` pins the same package the + /// same way). After uv re-serializes the overrides, the recorded + /// original still appears elsewhere — that must NOT read as convergence: + /// revert decides from the `overrides` key itself, so our element is + /// removed instead of stranded pointing at a deleted artifact. + #[tokio::test] + async fn revert_converges_on_the_overrides_key_not_a_lookalike_array() { + let user_py = format!( + "{TRANSITIVE_REGISTRY_PYPROJECT}\n[tool.uv]\noverride-dependencies = [\"python-dateutil==2.8.2\"]\n" + ); + let lookalike = "[{ name = \"python-dateutil\", specifier = \"==2.8.2\" }]"; + assert!( + TRANSITIVE_REGISTRY_LOCK.contains(&format!("requires-dist = {lookalike}")), + "the root requires-dist must be the lookalike" + ); + let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + &format!("requires-python = \">=3.10\"\n\n[manifest]\noverrides = {lookalike}\n"), + ); + let tmp = write_pair(&user_py, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (_, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let dateutil_el = "{ name = \"python-dateutil\", specifier = \"==2.8.2\" }"; + let ours = format!("overrides = [{dateutil_el}, {six_el}]"); + assert!(wired_lock.contains(&ours), "{wired_lock}"); + let uv_spelling = format!("overrides = [\n {dateutil_el},\n {six_el},\n]"); + tokio::fs::write( + tmp.path().join("uv.lock"), + wired_lock.replace(&ours, &uv_spelling), + ) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, user_py); + assert_eq!(lock, input_lock, "our element must not be stranded"); + } + /// The pair gate: when a uv.lock record is GENUINELY drift-kept (our /// element itself was edited and still routes through the artifact), /// revert must write NEITHER file — restoring pyproject.toml alone would From 77686c458fd6e779b49a2d0d7b00ed49aaa37508 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:17:36 +0000 Subject: [PATCH 06/15] Fail closed when a uv lock array can't be read Revert treated any miss locating a whole-array record as convergence, including a key spelled differently or an unbalanced array. A lock that still routed through the vendored wheel could then lose the wheel. Only a key or section that is provably absent now counts as converged. Anything unreadable is drift, which keeps both files and the artifact. Refs #806, #821. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/vendor/pypi_uv.rs | 120 +++++++++++++++--- 1 file changed, 105 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 9df6393f9..1b2d533cb 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -1105,10 +1105,12 @@ fn revert_array_elements( return ArrayRevert::Drift; } - let Some(span) = locate_lock_array(lock_text, scope, key) else { + let span = match locate_lock_array(lock_text, scope, key) { + Ok(span) => span, // The whole key is gone (uv drops an emptied group): nothing in it // routes through the artifact. - return ArrayRevert::Converged; + Err(ArrayMiss::Absent) => return ArrayRevert::Converged, + Err(ArrayMiss::Unreadable) => return ArrayRevert::Drift, }; if lock_text[span.clone()] == *new_array { // Untouched since vendoring: restore the recorded original verbatim. @@ -1158,15 +1160,46 @@ fn revert_array_elements( ArrayRevert::Reverted(text) } +/// Why [`locate_lock_array`] found no array. +enum ArrayMiss { + /// The section or the key is provably not in the lock (uv drops an + /// emptied group, or a `[manifest]` with nothing left in it). + Absent, + /// Something is there but not in a shape we can read: the root unit is + /// missing, the key is spelled differently, or the array is + /// unbalanced. Fail closed — it may still route through the artifact. + Unreadable, +} + /// Byte span of the `[…]` array assigned to `key` at line start inside -/// `scope`, or `None` when the section or key is absent. -fn locate_lock_array(lock_text: &str, scope: ArrayScope, key: &str) -> Option> { +/// `scope`. A miss is [`ArrayMiss::Absent`] only when no line in the scope +/// assigns `key` (or opens the section) in ANY spelling. +fn locate_lock_array( + lock_text: &str, + scope: ArrayScope, + key: &str, +) -> Result, ArrayMiss> { + // A line that assigns `key` however it is spaced or quoted. + let assigns_key = |line: &str| { + let l = line.trim_start(); + let rest = l + .strip_prefix(key) + .or_else(|| l.strip_prefix(&format!("\"{key}\""))); + rest.is_some_and(|r| r.trim_start().starts_with('=')) + }; let section = match scope { ArrayScope::RootRequiresDev => { - let unit = find_unit_span(lock_text, unit_is_root)?; + let unit = find_unit_span(lock_text, unit_is_root).ok_or(ArrayMiss::Unreadable)?; + let unit_text = &lock_text[unit.clone()]; let header = "[package.metadata.requires-dev]"; - let hdr = unit.start + lock_text[unit.clone()].find(header)?; - let start = hdr + header.len(); + let Some(hdr_rel) = unit_text.find(header) else { + return Err(if unit_text.contains("requires-dev") { + ArrayMiss::Unreadable + } else { + ArrayMiss::Absent + }); + }; + let start = unit.start + hdr_rel + header.len(); // Elements are indented, so a line-leading `[` is the next // sub-table header. let end = lock_text[start..unit.end] @@ -1176,9 +1209,18 @@ fn locate_lock_array(lock_text: &str, scope: ArrayScope, key: &str) -> Option { let index = line_index(lock_text); - let h = index - .iter() - .position(|(_, l)| l.trim_end() == "[manifest]")?; + let Some(h) = index.iter().position(|(_, l)| l.trim_end() == "[manifest]") else { + return Err( + if index + .iter() + .any(|(_, l)| l.trim_start().starts_with("[manifest")) + { + ArrayMiss::Unreadable + } else { + ArrayMiss::Absent + }, + ); + }; let end = index[h + 1..] .iter() .find(|(_, l)| l.starts_with('[')) @@ -1187,13 +1229,21 @@ fn locate_lock_array(lock_text: &str, scope: ArrayScope, key: &str) -> Option=3.10\"\n", + "requires-python = \">=3.10\"\n\n[manifest]\noverrides = [{ name = \"other\", path = \"o.whl\" }]\n", + ); + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + let respelled = wired_lock.replace("overrides = [", "overrides=["); + assert_ne!(respelled, wired_lock); + tokio::fs::write(tmp.path().join("uv.lock"), &respelled) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.drift_skipped(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, wired_py); + assert_eq!(lock, respelled); + } + /// The pair gate: when a uv.lock record is GENUINELY drift-kept (our /// element itself was edited and still routes through the artifact), /// revert must write NEITHER file — restoring pyproject.toml alone would From db244407ddd01c21794f1bb8163602cc59c262af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:30:16 +0000 Subject: [PATCH 07/15] Start fix for #840 Assisted-by: Claude Code:claude-opus-5-5 From 58ef065aef90175f2b5aeaed697901c6e99019a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:37:22 +0000 Subject: [PATCH 08/15] Test uv revert after a declaration edit A vendored uv revert writes back the lock specifier it recorded when vendoring. If the user changed the package's requirement in pyproject.toml in the meantime, the lock no longer matches and `uv sync --locked` fails. These tests pin the expected behaviour for requires-dist, requires-dev groups and [manifest] constraints. Refs #840 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/vendor/pypi_uv.rs | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 1b2d533cb..e9220306f 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -6508,4 +6508,219 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 assert_eq!(py, wired_py, "pyproject.toml must stay wired with its lock"); assert_eq!(lock, drifted_lock, "uv.lock is left exactly as found"); } + + // ── #840: the user edits the vendored package's own declaration ────── + + /// Vendor six from `pyproject`/`lock`, then do what `uv add ""` (or + /// a hand edit plus `uv lock`) does while six is vendored: pyproject.toml + /// swaps the declaration `from` → `to`, and uv.lock stays byte-identical + /// (a path source records no specifier, so uv has nothing to relock). + /// Returns the revert outcome plus the wired pair and the reverted pair. + async fn revert_after_declaration_edit( + pyproject: &str, + lock: &str, + from: &str, + to: &str, + ) -> (RevertOutcome, (String, String), (String, String)) { + let tmp = write_pair(pyproject, lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + let edited_py = wired_py.replace(from, to); + assert_ne!(edited_py, wired_py, "the edit must hit the declaration"); + tokio::fs::write(tmp.path().join("pyproject.toml"), &edited_py) + .await + .unwrap(); + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + let reverted = read_pair(tmp.path()).await; + (outcome, (edited_py, wired_lock), reverted) + } + + /// #840: `uv add "six>=1.15"` after vendoring. The revert must write the + /// specifier pyproject.toml declares NOW, not the recorded `==1.16.0` + /// (which leaves `uv sync --locked` red behind a successful revert). + #[tokio::test] + async fn revert_respells_an_edited_requires_dist_specifier() { + let (outcome, _, (py, lock)) = revert_after_declaration_edit( + DIRECT_REGISTRY_PYPROJECT, + DIRECT_REGISTRY_LOCK, + "\"six==1.16.0\"", + "\"six>=1.15\"", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + py, + DIRECT_REGISTRY_PYPROJECT.replace("\"six==1.16.0\"", "\"six>=1.15\"") + ); + assert_eq!( + lock, + DIRECT_REGISTRY_LOCK.replace( + "{ name = \"six\", specifier = \"==1.16.0\" }", + "{ name = \"six\", specifier = \">=1.15\" }" + ), + "the restored element carries the current declaration" + ); + } + + /// #840, PEP 735 group (`uv add --dev "six==1.16.*"`): the requires-dev + /// group array is restored with the group's current specifier. + #[tokio::test] + async fn revert_respells_an_edited_requires_dev_specifier() { + let (outcome, _, (py, lock)) = revert_after_declaration_edit( + DEV_GROUP_REGISTRY_PYPROJECT, + DEV_GROUP_REGISTRY_LOCK, + "dev = [\"six==1.16.0\"]", + "dev = [\"six==1.16.*\"]", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + py, + DEV_GROUP_REGISTRY_PYPROJECT.replace("\"six==1.16.0\"", "\"six==1.16.*\"") + ); + assert_eq!( + lock, + DEV_GROUP_REGISTRY_LOCK.replace( + "dev = [{ name = \"six\", specifier = \"==1.16.0\" }]", + "dev = [{ name = \"six\", specifier = \"==1.16.*\" }]" + ) + ); + } + + /// #840: dropping the version bound entirely (`six`) restores an element + /// with no `specifier`, which is how uv records an unconstrained + /// requirement. + #[tokio::test] + async fn revert_drops_the_specifier_of_an_unconstrained_declaration() { + let (outcome, _, (_, lock)) = revert_after_declaration_edit( + DIRECT_REGISTRY_PYPROJECT, + DIRECT_REGISTRY_LOCK, + "\"six==1.16.0\"", + "\"six\"", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + lock, + DIRECT_REGISTRY_LOCK.replace( + "{ name = \"six\", specifier = \"==1.16.0\" }", + "{ name = \"six\" }" + ) + ); + } + + /// #840, `[tool.uv] constraint-dependencies`: the `[manifest] + /// constraints` element follows the edited constraint, while the + /// unchanged `dependencies` element is restored byte-for-byte. + #[tokio::test] + async fn revert_respells_an_edited_manifest_constraint() { + let manifest = "[manifest]\nconstraints = [{ name = \"six\", specifier = \"==1.16.0\" }]\n\n"; + let registry_lock = + DIRECT_REGISTRY_LOCK.replacen("[[package]]", &format!("{manifest}[[package]]"), 1); + let (outcome, _, (_, lock)) = revert_after_declaration_edit( + CONSTRAINTS_REGISTRY_PYPROJECT, + ®istry_lock, + "constraint-dependencies = [\"six==1.16.0\"]", + "constraint-dependencies = [\"six>=1.16\"]", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + lock, + registry_lock.replace( + "constraints = [{ name = \"six\", specifier = \"==1.16.0\" }]", + "constraints = [{ name = \"six\", specifier = \">=1.16\" }]" + ) + ); + } + + /// #840, multi-clause: how uv joins clauses (`,` or `, `, sorted or not) + /// differs between releases. With a sibling entry showing this lock's + /// spelling, the edited requirement is written in it. + #[tokio::test] + async fn revert_respells_a_multi_clause_specifier_in_the_locks_style() { + let py_in = + DIRECT_REGISTRY_PYPROJECT.replace("[\"six==1.16.0\"]", "[\"attrs<30,>=20\", \"six==1.16.0\"]"); + let lock_in = DIRECT_REGISTRY_LOCK.replace( + "requires-dist = [{ name = \"six\", specifier = \"==1.16.0\" }]", + "requires-dist = [\n { name = \"attrs\", specifier = \">=20,<30\" },\n { name = \"six\", specifier = \"==1.16.0\" },\n]", + ); + let (outcome, _, (_, lock)) = revert_after_declaration_edit( + &py_in, + &lock_in, + "\"six==1.16.0\"", + "\"six<1.17,>=1.16\"", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + lock, + lock_in.replace( + "{ name = \"six\", specifier = \"==1.16.0\" }", + "{ name = \"six\", specifier = \">=1.16,<1.17\" }" + ), + "attrs shows this uv sorts clauses and joins them with `,`" + ); + } + + /// #840, multi-clause with nothing in the lock showing uv's spelling: + /// the correct specifier can't be derived, so restoring the stale one + /// would break `--locked`. Treat it as drift: keep BOTH files wired (the + /// pair gate) and warn, never report a clean revert. + #[tokio::test] + async fn revert_keeps_the_pair_when_an_edited_specifier_is_not_derivable() { + let (outcome, (edited_py, wired_lock), (py, lock)) = revert_after_declaration_edit( + DIRECT_REGISTRY_PYPROJECT, + DIRECT_REGISTRY_LOCK, + "\"six==1.16.0\"", + "\"six>=1.16,<1.17\"", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.drift_skipped(), "{:?}", outcome.warnings); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + assert_eq!(py, edited_py, "pyproject.toml stays wired with its lock"); + assert_eq!(lock, wired_lock, "uv.lock is left exactly as found"); + } + + /// An unchanged declaration, however it is spaced or ordered, still + /// restores the recorded element byte-for-byte. + #[tokio::test] + async fn revert_keeps_the_recorded_specifier_when_the_declaration_agrees() { + let (outcome, _, (_, lock)) = revert_after_declaration_edit( + DIRECT_REGISTRY_PYPROJECT, + DIRECT_REGISTRY_LOCK, + "\"six==1.16.0\"", + "\"six == 1.16.0 ; python_version >= '3.9'\"", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(lock, DIRECT_REGISTRY_LOCK); + } } From da11b9cdc27f42b8e28a57118c42982109ce8a88 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:45:11 +0000 Subject: [PATCH 09/15] Re-derive uv specifiers on vendored revert When six is vendored, uv.lock records it as a path source with no version specifier. If the user then changes six's requirement in pyproject.toml (uv add "six>=1.16"), the lock stays byte-identical, and vendor --revert, remove and rollback wrote back the specifier recorded at vendoring time. The revert reported success, but `uv sync --locked` then failed. The revert now writes the specifier pyproject.toml declares now, using the same derivation the hosted unwind uses. This covers requires-dist (each extra separately), requires-dev groups and [manifest] constraints. An unchanged declaration still restores byte-for-byte. When uv's spelling can't be derived, such as a multi-clause range whose clause order varies between uv releases, the revert keeps both files and warns vendor_lock_entry_drifted instead of breaking the lock. Fixes #840 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_pypi_build.rs | 25 +++ .../src/patch/redirect/upstream/mod.rs | 1 + .../src/patch/redirect/upstream/uv.rs | 117 ++++++++++++ .../socket-patch-core/src/vendor/pypi_uv.rs | 176 ++++++++++++++++-- 4 files changed, 301 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index c0da761ad..3ea510e2d 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -1065,6 +1065,31 @@ fn uv_vendor_revert_after_manifest_overrides_relock() { ); } +/// #840: `uv add "six>=1.16"` while six is vendored rewrites pyproject.toml +/// but leaves uv.lock byte-identical (a path source records no specifier). +/// The revert must write the NEW specifier back, not the recorded +/// `==1.16.0` that leaves `uv sync --locked` red. +#[test] +#[serial_test::serial] +fn uv_vendor_revert_after_declaration_edit() { + uv_relock_then_revert( + "uv-declaration-edit", + "[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\", \"attrs>=20\"]\n", + &["add", "-q", "six>=1.16"], + ); +} + +/// #840 in a PEP 735 dev group: `uv add --dev "six>=1.16"`. +#[test] +#[serial_test::serial] +fn uv_vendor_revert_after_dev_group_declaration_edit() { + uv_relock_then_revert( + "uv-dev-declaration-edit", + "[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = []\n\n[dependency-groups]\ndev = [\"six==1.16.0\", \"attrs>=20\"]\n", + &["add", "-q", "--dev", "six>=1.16"], + ); +} + /// `get --mode vendored` twin of the uv capstone above (v3.6): the /// SAME vendor engine and wiring, driven through get's uuid path — exempt /// from installed narrowing, so only the mocked `view/{uuid}` route is diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index ea0fe324f..be6889c8f 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -41,6 +41,7 @@ mod uv; mod vlt; pub(crate) use client::UpstreamClient; +pub(crate) use uv::{respell_lock_specifier, LockRequirementArray}; use super::staged::{flush_staged, read_rel, Staged, StagedBytes}; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index 6b853d3e8..4a561a740 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -974,6 +974,123 @@ fn declared_clauses( Ok(found) } +/// Which lock requirement array a vendored revert is restoring an entry +/// of, for [`respell_lock_specifier`]. +#[derive(Clone, Copy, Debug)] +pub(crate) enum LockRequirementArray<'a> { + /// The root `requires-dist`, for the entry carrying `extra` in its + /// marker (`marker = "extra == 'x'"`), or for `[project] + /// dependencies` when `None`. + RequiresDist { extra: Option<&'a str> }, + /// The root `requires-dev.`. + RequiresDev(&'a str), + /// `[manifest] constraints` / `build-constraints`. + Manifest(&'a str), +} + +/// The `specifier` a vendored revert should restore for `name`'s entry +/// in `array`, given the one it recorded when vendoring (`None` when the +/// entry had none). +/// +/// A path source records no specifier, so a user who changes the +/// declaration while the package is vendored (`uv add "six>=1.16"`) +/// leaves uv.lock byte-identical, and the recorded specifier goes stale +/// (#840). Returns: +/// * `Ok(None)`: keep the recorded entry as it is. The declaration still +/// agrees with it, nothing declares the name, or the declaration isn't a +/// plain version range (the recorded spelling was uv's own, so it stays +/// the best answer); +/// * `Ok(Some(spec))`: write `spec` instead, in uv's spelling (`None` is no +/// specifier at all); +/// * `Err`: the declaration changed but uv's spelling of it can't be +/// derived (a multi-clause range, or several conflicting declarations), +/// so restoring any spelling may break `--locked`. +pub(crate) fn respell_lock_specifier( + pyproject_text: &str, + array: LockRequirementArray<'_>, + name: &str, + recorded: Option<&str>, +) -> Result>, String> { + let Ok(doc) = pyproject_text.parse::() else { + return Ok(None); + }; + let meta = Metadata { + rel: "pyproject.toml".to_string(), + text: String::new(), + script: false, + doc, + }; + let canon = canonicalize_pypi_name(name); + let specs: Vec<&str> = match array { + LockRequirementArray::RequiresDist { extra: None } => { + strings(meta.doc.get("project").and_then(|p| p.get("dependencies"))) + } + LockRequirementArray::RequiresDist { extra: Some(extra) } => { + let extra = canonicalize_pypi_name(extra); + meta.doc + .get("project") + .and_then(|p| p.get("optional-dependencies")) + .and_then(Item::as_table_like) + .into_iter() + .flat_map(|groups| groups.iter()) + .filter(|(group, _)| canonicalize_pypi_name(group) == extra) + .flat_map(|(_, group)| strings(Some(group))) + .collect() + } + LockRequirementArray::RequiresDev(group) => declarations(&meta, Declared::Dev(group)), + LockRequirementArray::Manifest(key) => declarations(&meta, Declared::Manifest(key)), + }; + let sorted = |clauses: &[String]| { + let mut c = clauses.to_vec(); + c.sort(); + c + }; + // Each distinct clause set declared for the name, in declaration order. + let mut declared: Vec> = Vec::new(); + for spec in specs { + if canonicalize_pypi_name(pep508_name(spec)) != canon { + continue; + } + let Ok(clauses) = spec_clauses(spec) else { + return Ok(None); + }; + if !declared.iter().any(|d| sorted(d) == sorted(&clauses)) { + declared.push(clauses); + } + } + if declared.is_empty() { + return Ok(None); + } + let recorded = match recorded { + None => Vec::new(), + Some(r) => match spec_clauses(&format!("{canon}{r}")) { + Ok(clauses) => clauses, + Err(_) => return Ok(None), + }, + }; + if declared.iter().any(|d| sorted(d) == sorted(&recorded)) { + return Ok(None); + } + let [clauses] = declared.as_slice() else { + return Err(format!( + "pyproject.toml declares {name} with several specifiers, none of them the \ + recorded one, so which one the lock entry mirrors is not derivable" + )); + }; + match clauses.as_slice() { + [] => Ok(Some(None)), + [one] => Ok(Some(Some(one.clone()))), + // How uv orders and joins clauses differs between releases (0.8 + // orders them by version: `>=20,!=21.1.0,<30`), and the lock no + // longer shows this entry's spelling, so any guess may break + // `--locked`. + _ => Err(format!( + "pyproject.toml now declares {name} with a multi-clause specifier, whose \ + spelling in uv.lock is not derivable" + )), + } +} + /// Every lock requirement array with the declarations it mirrors /// (read-only twin of [`restore_requirements`]'s walk). fn requirement_arrays_ref(doc: &DocumentMut) -> Vec<(Declared<'_>, &toml_edit::Array)> { diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index e9220306f..8f3b76f9a 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -30,6 +30,7 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; // revert) forever in an `open(2)` that waits for a writer — the // flavor-routing probes ahead of the load are metadata-only, so these are // the first opens. +use crate::patch::redirect::upstream::{respell_lock_specifier, LockRequirementArray}; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::python_lock::preserve_line_endings; @@ -815,7 +816,30 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - for rec in entry.wiring.iter().rev() { let new_text = rec.new.as_ref().and_then(serde_json::Value::as_str); - let original_text = rec.original.as_ref().and_then(serde_json::Value::as_str); + let recorded = rec.original.as_ref().and_then(serde_json::Value::as_str); + // A root requirement entry lost its specifier to the path source, + // so a declaration the user changed since vendoring left the lock + // byte-identical: restore the specifier pyproject.toml declares + // NOW, never the recorded one blindly (#840). + let respelled = match (recorded, rec.key.as_deref()) { + (Some(orig), Some(key)) => { + match respell_original(orig, &rec.kind, key, &pyproject_text) { + Ok(text) => Some(text), + Err(reason) => { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "uv.lock entry for {key:?} can't be restored to match \ + pyproject.toml ({reason}); left untouched" + ), + )); + continue; + } + } + } + (orig, _) => orig.map(str::to_string), + }; + let original_text = respelled.as_deref(); let drifted = |what: &str| { VendorWarning::new( "vendor_lock_entry_drifted", @@ -1021,6 +1045,102 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // ── helpers ────────────────────────────────────────────────────────────── +/// The recorded pre-vendor `original` of a root requirement record +/// (`uv_lock_requires_dist` element, `uv_lock_requires_dev` group line, +/// `uv_lock_manifest_constraints` key line) with every `canon` entry's +/// `specifier` re-derived from the current pyproject.toml declaration +/// ([`respell_lock_specifier`]). Other record kinds, other packages' +/// entries and entries whose declaration still agrees come back +/// unchanged, so an untouched project still reverts byte-for-byte. `Err` +/// when the declaration changed but uv's spelling of it isn't derivable. +fn respell_original( + orig: &str, + kind: &str, + canon: &str, + pyproject_text: &str, +) -> Result { + // (offset of the element list in `orig`, the array it mirrors). + let (offset, array) = match kind { + "uv_lock_requires_dist" => (0, None), + "uv_lock_requires_dev" | "uv_lock_manifest_constraints" => { + let Some((key, _)) = orig.split_once(" = ") else { + return Ok(orig.to_string()); + }; + let key = key.trim().trim_matches('"'); + let array = if kind == "uv_lock_requires_dev" { + LockRequirementArray::RequiresDev(key) + } else { + LockRequirementArray::Manifest(key) + }; + (orig.find(" = ").map_or(0, |i| i + " = ".len()), Some(array)) + } + _ => return Ok(orig.to_string()), + }; + let needle = format!("name = \"{canon}\""); + let mut out = orig.to_string(); + // Back-to-front so the earlier spans stay valid. + for (s, e) in top_level_brace_groups(&orig[offset..]).into_iter().rev() { + let (s, e) = (offset + s, offset + e); + let element = &orig[s..e]; + if !element.contains(&needle) { + continue; + } + let array = array.unwrap_or(LockRequirementArray::RequiresDist { + extra: marker_extra(element), + }); + let specifier = entry_value(element, "specifier"); + if let Some(spec) = respell_lock_specifier(pyproject_text, array, canon, specifier)? { + out.replace_range(s..e, &with_specifier(element, spec.as_deref())); + } + } + Ok(out) +} + +/// The string value of `key` in a one-line `{ k = "v", … }` lock entry. +fn entry_value<'a>(entry: &'a str, key: &str) -> Option<&'a str> { + let inner = entry.trim().strip_prefix('{')?.strip_suffix('}')?; + split_top_level_commas(inner).into_iter().find_map(|part| { + part.trim() + .strip_prefix(key)? + .trim_start() + .strip_prefix('=') + .map(|v| v.trim().trim_matches('"')) + }) +} + +/// The extra a root `requires-dist` entry belongs to: uv records an +/// optional-dependencies requirement with `extra == ''` in its marker. +fn marker_extra(entry: &str) -> Option<&str> { + let marker = entry_value(entry, "marker")?; + let rest = &marker[marker.find("extra == '")? + "extra == '".len()..]; + rest.split('\'').next() +} + +/// `entry` with its `specifier` replaced by `spec` (removed when `None`), +/// in place, or appended last, where uv writes it, when it had none. +fn with_specifier(entry: &str, spec: Option<&str>) -> String { + let inner = entry.trim().trim_start_matches('{').trim_end_matches('}'); + let rendered = spec.map(|s| format!("specifier = \"{s}\"")); + let mut parts: Vec = Vec::new(); + let mut placed = false; + for part in split_top_level_commas(inner) { + let part = part.trim(); + if part.is_empty() { + continue; + } + if part.starts_with("specifier =") { + parts.extend(rendered.clone()); + placed = true; + continue; + } + parts.push(part.to_string()); + } + if !placed { + parts.extend(rendered); + } + format!("{{ {} }}", parts.join(", ")) +} + /// Result of [`revert_array_elements`]. enum ArrayRevert { /// Our elements were restored or removed; the new lock text. @@ -6630,7 +6750,8 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 /// unchanged `dependencies` element is restored byte-for-byte. #[tokio::test] async fn revert_respells_an_edited_manifest_constraint() { - let manifest = "[manifest]\nconstraints = [{ name = \"six\", specifier = \"==1.16.0\" }]\n\n"; + let manifest = + "[manifest]\nconstraints = [{ name = \"six\", specifier = \"==1.16.0\" }]\n\n"; let registry_lock = DIRECT_REGISTRY_LOCK.replacen("[[package]]", &format!("{manifest}[[package]]"), 1); let (outcome, _, (_, lock)) = revert_after_declaration_edit( @@ -6651,18 +6772,19 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 ); } - /// #840, multi-clause: how uv joins clauses (`,` or `, `, sorted or not) - /// differs between releases. With a sibling entry showing this lock's - /// spelling, the edited requirement is written in it. + /// #840, multi-clause: uv's clause order differs between releases (0.8 + /// orders by version, `>=20,!=21.1.0,<30`), so even a sibling entry + /// can't show how this edit would be spelled. Fail closed: keep the + /// wired pair and warn rather than guess. #[tokio::test] - async fn revert_respells_a_multi_clause_specifier_in_the_locks_style() { - let py_in = - DIRECT_REGISTRY_PYPROJECT.replace("[\"six==1.16.0\"]", "[\"attrs<30,>=20\", \"six==1.16.0\"]"); + async fn revert_keeps_the_pair_on_a_multi_clause_edit_despite_sibling_evidence() { + let py_in = DIRECT_REGISTRY_PYPROJECT + .replace("[\"six==1.16.0\"]", "[\"attrs>=20,<30\", \"six==1.16.0\"]"); let lock_in = DIRECT_REGISTRY_LOCK.replace( "requires-dist = [{ name = \"six\", specifier = \"==1.16.0\" }]", "requires-dist = [\n { name = \"attrs\", specifier = \">=20,<30\" },\n { name = \"six\", specifier = \"==1.16.0\" },\n]", ); - let (outcome, _, (_, lock)) = revert_after_declaration_edit( + let (outcome, (edited_py, wired_lock), (py, lock)) = revert_after_declaration_edit( &py_in, &lock_in, "\"six==1.16.0\"", @@ -6670,15 +6792,9 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 ) .await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); - assert_eq!( - lock, - lock_in.replace( - "{ name = \"six\", specifier = \"==1.16.0\" }", - "{ name = \"six\", specifier = \">=1.16,<1.17\" }" - ), - "attrs shows this uv sorts clauses and joins them with `,`" - ); + assert!(outcome.drift_skipped(), "{:?}", outcome.warnings); + assert_eq!(py, edited_py); + assert_eq!(lock, wired_lock); } /// #840, multi-clause with nothing in the lock showing uv's spelling: @@ -6708,6 +6824,30 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 assert_eq!(lock, wired_lock, "uv.lock is left exactly as found"); } + /// #840 with six in both `dependencies` and an extra: each requires-dist + /// element follows its OWN declaration (uv marks the extra's with + /// `extra == 'socks'`), so editing only the extra respells only that + /// element. + #[tokio::test] + async fn revert_respells_only_the_edited_extra_element() { + let (outcome, _, (_, lock)) = revert_after_declaration_edit( + EXTRAS_DUP_REGISTRY_PYPROJECT, + EXTRAS_DUP_REGISTRY_LOCK, + "socks = [\"six==1.16.0\"]", + "socks = [\"six>=1.16\"]", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + lock, + EXTRAS_DUP_REGISTRY_LOCK.replace( + "marker = \"extra == 'socks'\", specifier = \"==1.16.0\"", + "marker = \"extra == 'socks'\", specifier = \">=1.16\"" + ) + ); + } + /// An unchanged declaration, however it is spaced or ordered, still /// restores the recorded element byte-for-byte. #[tokio::test] From 767d6e92bdff669afa349f34781b560a4b339603 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:52:49 +0000 Subject: [PATCH 10/15] Document uv revert after a declaration edit Refs #840 Assisted-by: Claude Code:claude-opus-5-5 --- docs/testing/uv-compatibility.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 427a1367c..8f378de7a 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -179,7 +179,14 @@ than restoring only one side. When any uv.lock or pyproject.toml record has drifted, neither file is written. A relock that only re-serializes an array around socket-patch's unchanged element is not drift: `uv add --dev x` rewrites the dev group's `requires-dev` line, and `uv add y` sorts `[manifest] overrides` -into its multi-line form. Revert restores or removes just that element. Tests also cover restoring one package while +into its multi-line form. Revert restores or removes just that element. +A path source records no specifier, so changing the vendored package's own +declaration (`uv add "six>=1.16"`) leaves uv.lock unchanged. Revert then +restores the `requires-dist`, `requires-dev` and `[manifest] constraints` +entries with the specifier pyproject.toml declares at revert time, not the one +recorded when vendoring. When uv's spelling can't be derived, as with a +multi-clause range (uv orders clauses differently across releases), the edit is +treated as drift and both files are kept. Tests also cover restoring one package while preserving another package's vendored entries. ## Reproduce the release-family matrix From 005404c3efa9b7d9a987d3a0321466f6f0617d2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:55:10 +0000 Subject: [PATCH 11/15] Adapt uv specifier re-derivation to main #625 on main changed the uv declaration reader to report each optional-dependencies member's extra. The merge of main into this branch no longer compiled. Use that reader for requires-dist instead of the local extras walk. Dev groups now also pick up main's group-name normalization and include-group expansion. Refs #840 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/upstream/uv.rs | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index 838c007b8..a053def9f 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -1229,23 +1229,22 @@ pub(crate) fn respell_lock_specifier( }; let canon = canonicalize_pypi_name(name); let specs: Vec<&str> = match array { - LockRequirementArray::RequiresDist { extra: None } => { - strings(meta.doc.get("project").and_then(|p| p.get("dependencies"))) - } - LockRequirementArray::RequiresDist { extra: Some(extra) } => { - let extra = canonicalize_pypi_name(extra); - meta.doc - .get("project") - .and_then(|p| p.get("optional-dependencies")) - .and_then(Item::as_table_like) + LockRequirementArray::RequiresDist { extra } => { + let extra = extra.map(canonicalize_pypi_name); + declarations(&meta, Declared::Dist) .into_iter() - .flat_map(|groups| groups.iter()) - .filter(|(group, _)| canonicalize_pypi_name(group) == extra) - .flat_map(|(_, group)| strings(Some(group))) + .filter(|d| d.extra == extra) + .map(|d| d.spec) .collect() } - LockRequirementArray::RequiresDev(group) => declarations(&meta, Declared::Dev(group)), - LockRequirementArray::Manifest(key) => declarations(&meta, Declared::Manifest(key)), + LockRequirementArray::RequiresDev(group) => declarations(&meta, Declared::Dev(group)) + .into_iter() + .map(|d| d.spec) + .collect(), + LockRequirementArray::Manifest(key) => declarations(&meta, Declared::Manifest(key)) + .into_iter() + .map(|d| d.spec) + .collect(), }; let sorted = |clauses: &[String]| { let mut c = clauses.to_vec(); From 5307d522b26d78d0d4b0f120b87da38b438f5066 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:09:38 +0000 Subject: [PATCH 12/15] Pick the declaration a uv lock entry mirrors When a package is declared twice, for example under two environment markers, or directly and through an include-group, the revert kept the recorded specifier whenever any one declaration still matched it. Edit just one of them and the stale pin came back, with the same broken `uv sync --locked` as #840. The revert now picks the declaration by the entry's own marker, as the hosted unwind does. Declarations that still disagree after that are treated as drift, and both files are kept. Refs #840 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/upstream/uv.rs | 85 +++++++------------ .../socket-patch-core/src/vendor/pypi_uv.rs | 71 +++++++++++++--- 2 files changed, 89 insertions(+), 67 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index a053def9f..43a82b4bf 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -1185,10 +1185,8 @@ fn declared_clauses( /// of, for [`respell_lock_specifier`]. #[derive(Clone, Copy, Debug)] pub(crate) enum LockRequirementArray<'a> { - /// The root `requires-dist`, for the entry carrying `extra` in its - /// marker (`marker = "extra == 'x'"`), or for `[project] - /// dependencies` when `None`. - RequiresDist { extra: Option<&'a str> }, + /// The root `requires-dist`: `[project]` dependencies and extras. + RequiresDist, /// The root `requires-dev.`. RequiresDev(&'a str), /// `[manifest] constraints` / `build-constraints`. @@ -1197,26 +1195,29 @@ pub(crate) enum LockRequirementArray<'a> { /// The `specifier` a vendored revert should restore for `name`'s entry /// in `array`, given the one it recorded when vendoring (`None` when the -/// entry had none). +/// entry had none) and the entry's `marker`. /// /// A path source records no specifier, so a user who changes the /// declaration while the package is vendored (`uv add "six>=1.16"`) /// leaves uv.lock byte-identical, and the recorded specifier goes stale -/// (#840). Returns: +/// (#840). The entry's declaration is picked the way hosted unwind picks +/// it ([`declared_clauses`]: by the extra and environment marker uv +/// lowered into the entry). Returns: /// * `Ok(None)`: keep the recorded entry as it is. The declaration still -/// agrees with it, nothing declares the name, or the declaration isn't a -/// plain version range (the recorded spelling was uv's own, so it stays -/// the best answer); +/// agrees with it, nothing declares the name, or a declaration of it +/// isn't a plain version range (the recorded spelling was uv's own, so +/// it stays the best answer); /// * `Ok(Some(spec))`: write `spec` instead, in uv's spelling (`None` is no /// specifier at all); /// * `Err`: the declaration changed but uv's spelling of it can't be -/// derived (a multi-clause range, or several conflicting declarations), -/// so restoring any spelling may break `--locked`. +/// derived (a multi-clause range), or which declaration the entry +/// mirrors is ambiguous, so restoring any spelling may break `--locked`. pub(crate) fn respell_lock_specifier( pyproject_text: &str, array: LockRequirementArray<'_>, name: &str, recorded: Option<&str>, + marker: Option<&str>, ) -> Result>, String> { let Ok(doc) = pyproject_text.parse::() else { return Ok(None); @@ -1227,44 +1228,16 @@ pub(crate) fn respell_lock_specifier( script: false, doc, }; - let canon = canonicalize_pypi_name(name); - let specs: Vec<&str> = match array { - LockRequirementArray::RequiresDist { extra } => { - let extra = extra.map(canonicalize_pypi_name); - declarations(&meta, Declared::Dist) - .into_iter() - .filter(|d| d.extra == extra) - .map(|d| d.spec) - .collect() - } - LockRequirementArray::RequiresDev(group) => declarations(&meta, Declared::Dev(group)) - .into_iter() - .map(|d| d.spec) - .collect(), - LockRequirementArray::Manifest(key) => declarations(&meta, Declared::Manifest(key)) - .into_iter() - .map(|d| d.spec) - .collect(), - }; - let sorted = |clauses: &[String]| { - let mut c = clauses.to_vec(); - c.sort(); - c + let declared = match array { + LockRequirementArray::RequiresDist => Declared::Dist, + LockRequirementArray::RequiresDev(group) => Declared::Dev(group), + LockRequirementArray::Manifest(key) => Declared::Manifest(key), }; - // Each distinct clause set declared for the name, in declaration order. - let mut declared: Vec> = Vec::new(); - for spec in specs { - if canonicalize_pypi_name(pep508_name(spec)) != canon { - continue; - } - let Ok(clauses) = spec_clauses(spec) else { - return Ok(None); - }; - if !declared.iter().any(|d| sorted(d) == sorted(&clauses)) { - declared.push(clauses); - } - } - if declared.is_empty() { + let canon = canonicalize_pypi_name(name); + let unreadable = declarations(&meta, declared).iter().any(|d| { + canonicalize_pypi_name(pep508_name(d.spec)) == canon && spec_clauses(d.spec).is_err() + }); + if unreadable { return Ok(None); } let recorded = match recorded { @@ -1274,15 +1247,17 @@ pub(crate) fn respell_lock_specifier( Err(_) => return Ok(None), }, }; - if declared.iter().any(|d| sorted(d) == sorted(&recorded)) { + let Some(clauses) = declared_clauses(&meta, declared, name, marker)? else { return Ok(None); - } - let [clauses] = declared.as_slice() else { - return Err(format!( - "pyproject.toml declares {name} with several specifiers, none of them the \ - recorded one, so which one the lock entry mirrors is not derivable" - )); }; + let sorted = |clauses: &[String]| { + let mut c = clauses.to_vec(); + c.sort(); + c + }; + if sorted(&clauses) == sorted(&recorded) { + return Ok(None); + } match clauses.as_slice() { [] => Ok(Some(None)), [one] => Ok(Some(Some(one.clone()))), diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index bfacf9837..03b9c38f7 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -1085,11 +1085,11 @@ fn respell_original( if !element.contains(&needle) { continue; } - let array = array.unwrap_or(LockRequirementArray::RequiresDist { - extra: marker_extra(element), - }); + let array = array.unwrap_or(LockRequirementArray::RequiresDist); let specifier = entry_value(element, "specifier"); - if let Some(spec) = respell_lock_specifier(pyproject_text, array, canon, specifier)? { + let marker = entry_value(element, "marker"); + if let Some(spec) = respell_lock_specifier(pyproject_text, array, canon, specifier, marker)? + { out.replace_range(s..e, &with_specifier(element, spec.as_deref())); } } @@ -1108,14 +1108,6 @@ fn entry_value<'a>(entry: &'a str, key: &str) -> Option<&'a str> { }) } -/// The extra a root `requires-dist` entry belongs to: uv records an -/// optional-dependencies requirement with `extra == ''` in its marker. -fn marker_extra(entry: &str) -> Option<&str> { - let marker = entry_value(entry, "marker")?; - let rest = &marker[marker.find("extra == '")? + "extra == '".len()..]; - rest.split('\'').next() -} - /// `entry` with its `specifier` replaced by `spec` (removed when `None`), /// in place, or appended last, where uv writes it, when it had none. fn with_specifier(entry: &str, spec: Option<&str>) -> String { @@ -7023,6 +7015,61 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 ); } + /// Bugbot on #841: six declared twice under complementary environment + /// markers, both `==1.16.0` when vendored. Editing only the second + /// must respell only the entry whose marker mirrors it, never keep the + /// stale pin because the OTHER declaration still matches it. + #[tokio::test] + async fn revert_respells_the_entry_whose_marker_mirrors_the_edit() { + let py_in = DIRECT_REGISTRY_PYPROJECT.replace( + "[\"six==1.16.0\"]", + "[\"six==1.16.0; python_version < '3.12'\", \"six==1.16.0; python_version >= '3.12'\"]", + ); + let lock_in = DIRECT_REGISTRY_LOCK.replace( + "requires-dist = [{ name = \"six\", specifier = \"==1.16.0\" }]", + "requires-dist = [\n { name = \"six\", marker = \"python_full_version < '3.12'\", specifier = \"==1.16.0\" },\n { name = \"six\", marker = \"python_full_version >= '3.12'\", specifier = \"==1.16.0\" },\n]", + ); + let (outcome, _, (_, lock)) = revert_after_declaration_edit( + &py_in, + &lock_in, + "\"six==1.16.0; python_version >= '3.12'\"", + "\"six>=1.16; python_version >= '3.12'\"", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + lock, + lock_in.replace( + "marker = \"python_full_version >= '3.12'\", specifier = \"==1.16.0\"", + "marker = \"python_full_version >= '3.12'\", specifier = \">=1.16\"" + ) + ); + } + + /// Bugbot on #841: an `include-group` still carries the old pin after + /// the group's own declaration was edited. The two declarations now + /// disagree and the entry's marker can't tell them apart, so keep the + /// wired pair (drift) instead of restoring the stale specifier. + #[tokio::test] + async fn revert_keeps_the_pair_when_group_declarations_disagree() { + let py_in = DEV_GROUP_REGISTRY_PYPROJECT.replace( + "dev = [\"six==1.16.0\"]", + "base = [\"six==1.16.0\"]\ndev = [\"six==1.16.0\", { include-group = \"base\" }]", + ); + let (outcome, (edited_py, wired_lock), (py, lock)) = revert_after_declaration_edit( + &py_in, + DEV_GROUP_REGISTRY_LOCK, + "dev = [\"six==1.16.0\",", + "dev = [\"six>=1.16\",", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.drift_skipped(), "{:?}", outcome.warnings); + assert_eq!(py, edited_py); + assert_eq!(lock, wired_lock); + } + /// An unchanged declaration, however it is spaced or ordered, still /// restores the recorded element byte-for-byte. #[tokio::test] From 872bd8cfe2ce8f02535564e913e732a1ea7db540 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 13:01:57 +0000 Subject: [PATCH 13/15] Tighten uv revert specifier re-derivation Two cases Bugbot found on the vendored uv revert: - A same-name declaration that isn't a plain version range, such as an extra pinned with ===, stopped every entry from following its edited declaration. Now only the entry whose own declaration is unreadable keeps its recorded spelling. - After a bound was dropped, the restored { name = "six" } element also matched another dependency entry in uv.lock, so a drifted wiring could pass as already reverted. The check now looks only in the root unit's requires-dist array. Refs #840 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/upstream/uv.rs | 28 +++--- .../socket-patch-core/src/vendor/pypi_uv.rs | 99 ++++++++++++++++++- 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index 43a82b4bf..a276628b9 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -1204,9 +1204,9 @@ pub(crate) enum LockRequirementArray<'a> { /// it ([`declared_clauses`]: by the extra and environment marker uv /// lowered into the entry). Returns: /// * `Ok(None)`: keep the recorded entry as it is. The declaration still -/// agrees with it, nothing declares the name, or a declaration of it -/// isn't a plain version range (the recorded spelling was uv's own, so -/// it stays the best answer); +/// agrees with it, nothing declares the name, or no declaration of it is +/// a plain version range (the recorded spelling was uv's own, so it +/// stays the best answer); /// * `Ok(Some(spec))`: write `spec` instead, in uv's spelling (`None` is no /// specifier at all); /// * `Err`: the declaration changed but uv's spelling of it can't be @@ -1234,12 +1234,6 @@ pub(crate) fn respell_lock_specifier( LockRequirementArray::Manifest(key) => Declared::Manifest(key), }; let canon = canonicalize_pypi_name(name); - let unreadable = declarations(&meta, declared).iter().any(|d| { - canonicalize_pypi_name(pep508_name(d.spec)) == canon && spec_clauses(d.spec).is_err() - }); - if unreadable { - return Ok(None); - } let recorded = match recorded { None => Vec::new(), Some(r) => match spec_clauses(&format!("{canon}{r}")) { @@ -1247,8 +1241,20 @@ pub(crate) fn respell_lock_specifier( Err(_) => return Ok(None), }, }; - let Some(clauses) = declared_clauses(&meta, declared, name, marker)? else { - return Ok(None); + let clauses = match declared_clauses(&meta, declared, name, marker) { + Ok(Some(clauses)) => clauses, + Ok(None) => return Ok(None), + // Not one declaration of the name is a plain version range: the + // recorded spelling was uv's own, so it stays the best answer. + // Otherwise the marker narrowing left an unreadable or conflicting + // set, which is ambiguous: fail closed. + Err(reason) => { + let all_unreadable = declarations(&meta, declared) + .iter() + .filter(|d| canonicalize_pypi_name(pep508_name(d.spec)) == canon) + .all(|d| spec_clauses(d.spec).is_err()); + return if all_unreadable { Ok(None) } else { Err(reason) }; + } }; let sorted = |clauses: &[String]| { let mut c = clauses.to_vec(); diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 03b9c38f7..d8d8bc743 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -874,7 +874,15 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // the fragment. Not drift: stay silent so the // drift-keep gate can converge instead of keeping // the artifact dir and ledger entry forever. - if original_text.is_some_and(|orig| lock_text.contains(orig)) { + // A requires-dist element is looked for in the root + // unit's own array: a short one (`{ name = "six" }`) + // also spells another package's dependency. + let haystack = if rec.kind == "uv_lock_requires_dist" { + root_requires_dist(&lock_text).unwrap_or("") + } else { + lock_text.as_str() + }; + if original_text.is_some_and(|orig| haystack.contains(orig)) { continue; } warnings.push(drifted("uv.lock")); @@ -1096,6 +1104,19 @@ fn respell_original( Ok(out) } +/// The root unit's `requires-dist = [ … ]` array text, if it has one. +fn root_requires_dist(lock_text: &str) -> Option<&str> { + let unit = find_unit_span(lock_text, unit_is_root)?; + let unit_text = &lock_text[unit.clone()]; + // requires-dist sits in [package.metadata], ahead of requires-dev. + let dist_scan = &unit_text[..unit_text + .find("[package.metadata.requires-dev]") + .unwrap_or(unit_text.len())]; + let open = dist_scan.find("requires-dist = [")? + "requires-dist = ".len(); + let end = balanced_span(unit_text, open)?; + Some(&unit_text[open..end]) +} + /// The string value of `key` in a one-line `{ k = "v", … }` lock entry. fn entry_value<'a>(entry: &'a str, key: &str) -> Option<&'a str> { let inner = entry.trim().strip_prefix('{')?.strip_suffix('}')?; @@ -7070,6 +7091,82 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 assert_eq!(lock, wired_lock); } + /// Bugbot on #841: an extra pinning six with `===` (not a plain range) + /// must not stop the `dependencies` entry, which the marker narrowing + /// tells apart, from following its edited declaration. + #[tokio::test] + async fn revert_respells_past_an_unreadable_sibling_declaration() { + let py_in = EXTRAS_DUP_REGISTRY_PYPROJECT.replace( + "socks = [\"six==1.16.0\"]", + "socks = [\"six===1.16.0\"]", + ); + let lock_in = EXTRAS_DUP_REGISTRY_LOCK.replace( + "marker = \"extra == 'socks'\", specifier = \"==1.16.0\"", + "marker = \"extra == 'socks'\", specifier = \"===1.16.0\"", + ); + let (outcome, _, (_, lock)) = revert_after_declaration_edit( + &py_in, + &lock_in, + "dependencies = [\"six==1.16.0\"]", + "dependencies = [\"six>=1.15\"]", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + lock, + lock_in.replace( + " { name = \"six\", specifier = \"==1.16.0\" },", + " { name = \"six\", specifier = \">=1.15\" }," + ), + "the extra's `===` entry keeps uv's own recorded spelling" + ); + } + + /// Bugbot on #841: after the bound is dropped, the restored element is + /// `{ name = "six" }`, which the root unit's `dependencies` array also + /// spells. If our wired requires-dist element was edited, that must + /// stay drift (both files kept), not pass as already converged. + #[tokio::test] + async fn revert_does_not_converge_on_a_lookalike_dependency_entry() { + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + let edited_py = wired_py.replace("\"six==1.16.0\"", "\"six\""); + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let drifted_el = format!( + "{{ name = \"six\", marker = \"python_full_version >= '3.11'\", path = \"{REL_WHEEL}\" }}" + ); + let drifted_lock = wired_lock.replace(&six_el, &drifted_el); + assert_ne!(drifted_lock, wired_lock); + assert!(drifted_lock.contains("{ name = \"six\" }"), "the lookalike"); + tokio::fs::write(tmp.path().join("pyproject.toml"), &edited_py) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("uv.lock"), &drifted_lock) + .await + .unwrap(); + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.drift_skipped(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, edited_py); + assert_eq!(lock, drifted_lock); + } + /// An unchanged declaration, however it is spaced or ordered, still /// restores the recorded element byte-for-byte. #[tokio::test] From c949cece0ff43ac504f8498194df496317599352 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 13:02:50 +0000 Subject: [PATCH 14/15] Format the uv revert re-derivation changes Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/patch/redirect/upstream/uv.rs | 6 +++++- crates/socket-patch-core/src/vendor/pypi_uv.rs | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index a276628b9..3b3e5ec0e 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -1253,7 +1253,11 @@ pub(crate) fn respell_lock_specifier( .iter() .filter(|d| canonicalize_pypi_name(pep508_name(d.spec)) == canon) .all(|d| spec_clauses(d.spec).is_err()); - return if all_unreadable { Ok(None) } else { Err(reason) }; + return if all_unreadable { + Ok(None) + } else { + Err(reason) + }; } }; let sorted = |clauses: &[String]| { diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index d8d8bc743..7ccd493cf 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -7096,10 +7096,8 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 /// tells apart, from following its edited declaration. #[tokio::test] async fn revert_respells_past_an_unreadable_sibling_declaration() { - let py_in = EXTRAS_DUP_REGISTRY_PYPROJECT.replace( - "socks = [\"six==1.16.0\"]", - "socks = [\"six===1.16.0\"]", - ); + let py_in = EXTRAS_DUP_REGISTRY_PYPROJECT + .replace("socks = [\"six==1.16.0\"]", "socks = [\"six===1.16.0\"]"); let lock_in = EXTRAS_DUP_REGISTRY_LOCK.replace( "marker = \"extra == 'socks'\", specifier = \"==1.16.0\"", "marker = \"extra == 'socks'\", specifier = \"===1.16.0\"", From 02735f58b173489d8ff504caafe36e1d57cda235 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 13:16:42 +0000 Subject: [PATCH 15/15] Port #851: fix vex alias tests broken on main main has been red since #605 (4646693). Two vex_consumed tests from #738 assumed the name-keyed copy resolver never returns npm-aliased copies, and #605 taught it to. This ports #851's tests-only fix unchanged so this PR's coverage and macOS test jobs can go green; it no-ops once #851 lands on main. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/vex_consumed.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index b57d475fb..cb0c68023 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -715,8 +715,11 @@ mod tests { None, ) .await; - assert_eq!(installed_again, installed); - let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await; + // Since #605 the name-keyed resolver probes bundled trees itself, so + // it already returns the aliases and the nested store's peers. Feed + // the earlier, alias-free set to keep exercising alias expansion; + // the resolver's own set is checked against the same result below. + let (paths, calls) = tracked_npm_hosted(&common, &installed).await; assert_eq!(calls.len(), 1); let mut inputs = calls[0].clone(); inputs.sort(); @@ -738,6 +741,9 @@ mod tests { .len(), paths.len() ); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] @@ -768,14 +774,19 @@ mod tests { None, ) .await; - assert!(installed.is_empty(), "{installed:?}"); - let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await; + // Since #605 the name-keyed resolver reaches the alias and its + // sibling peers on its own. An alias-only set (what an alias-blind + // resolver returns) must still expand to the same copies. + let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await; assert_eq!(calls, vec![vec![alias.clone()]]); let mut expected = peers; expected.push(alias); paths.sort(); expected.sort(); assert_eq!(paths, expected); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)]