From ac0a657037f39213713acd9293fd1293ee90434f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:30:10 +0000 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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