diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 96c99623..c2d92de1 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -63,9 +63,14 @@ fn hosted_wheel() -> Vec { /// Stage `.socket/manifest.json` + the after-hash blob so `vendor` builds /// the vendored wheel from the prebuilt fixture server, offline. fn stage_manifest(root: &Path) { - let after = compute_git_sha256_from_bytes(PATCHED); + stage_manifest_with(root, UUID, PATCHED); +} + +/// [`stage_manifest`] for patch `uuid` whose patched `six.py` is `patched`. +fn stage_manifest_with(root: &Path, uuid: &str, patched: &[u8]) { + let after = compute_git_sha256_from_bytes(patched); let manifest = json!({ "patches": { PURL: { - "uuid": UUID, + "uuid": uuid, "exportedAt": "2026-01-01T00:00:00Z", "files": { "six.py": { "beforeHash": compute_git_sha256_from_bytes(ORIG), @@ -83,7 +88,7 @@ fn stage_manifest(root: &Path) { serde_json::to_vec_pretty(&manifest).unwrap(), ) .unwrap(); - std::fs::write(socket.join("blobs").join(after), PATCHED).unwrap(); + std::fs::write(socket.join("blobs").join(after), patched).unwrap(); } /// The built binary with every ambient `SOCKET_*` var scrubbed. An EMPTY @@ -292,6 +297,86 @@ python-versions = ">=3.9" content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" "#; +/// #765: a vendored requirements.txt picks up a superseding patch. The +/// manifest moves `six` from patch A to patch B (different patched bytes); +/// the next `vendor` must re-wire the requirements line to B's wheel in +/// place, remove A's uuid dir (`vendor_stale_artifact_removed`) and exit 0. +/// Before the fix it failed `pypi_requirements_already_vendored` (exit 1) +/// and pip kept installing patch A. `vendor --revert` afterwards restores +/// the user's original pin, so the carried-over ledger record is intact. +#[tokio::test] +async fn requirements_vendored_revendors_superseding_patch() { + const UUID_B: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6d"; + const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n"; + for original in [ + "idna==3.7\nsix==1.16.0\n".to_string(), + format!( + "idna==3.7 --hash=sha256:{}\nsix==1.16.0 ; python_version >= \"3\" \\\n --hash=sha256:{WHEEL_SHA}\n", + "1".repeat(64) + ), + ] { + let (_tmp, root) = project(); + std::fs::write(root.join("requirements.txt"), &original).unwrap(); + vendor_project(&root, &["requirements.txt"]); + let wired_a = std::fs::read_to_string(root.join("requirements.txt")).unwrap(); + + stage_manifest_with(&root, UUID_B, PATCHED_B); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "re-vendor to the superseding patch: {env:#}"); + let rendered = env.to_string(); + assert!( + !rendered.contains("pypi_requirements_already_vendored"), + "{env:#}" + ); + assert!( + rendered.contains("vendor_stale_artifact_removed"), + "patch A's artifact is reclaimed: {env:#}" + ); + let wired_b = std::fs::read_to_string(root.join("requirements.txt")).unwrap(); + assert!(!wired_b.contains(UUID), "uuid A is gone:\n{wired_b}"); + assert_eq!( + wired_b.matches(&format!(".socket/vendor/pypi/{UUID_B}/")).count(), + 1, + "one six line, on patch B:\n{wired_b}" + ); + assert_eq!( + wired_b.lines().count(), + wired_a.lines().count(), + "re-wired in place:\n{wired_a}\n{wired_b}" + ); + assert_eq!( + wired_b.contains("--hash="), + original.contains("--hash="), + "hash mode kept:\n{wired_b}" + ); + assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); + let wheel_b = wired_b + .split_whitespace() + .find(|t| t.contains(UUID_B)) + .unwrap(); + assert!(root.join(wheel_b).is_file(), "patch B's wheel: {wheel_b}"); + let ledger = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(); + assert!(ledger.contains(UUID_B) && !ledger.contains(UUID), "{ledger}"); + + // Re-running is settled: in sync, nothing rewritten. + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!( + std::fs::read_to_string(root.join("requirements.txt")).unwrap(), + wired_b + ); + + let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]); + assert_eq!(code, 0, "revert after the re-vendor: {env:#}"); + assert_eq!( + std::fs::read_to_string(root.join("requirements.txt")).unwrap(), + original, + "the user's own pin is restored" + ); + assert!(!root.join(format!(".socket/vendor/pypi/{UUID_B}")).exists()); + } +} + #[tokio::test] async fn requirements_vendored_to_hosted() { let (_tmp, root) = project(); diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 74883c23..b9afe9b7 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -29,7 +29,8 @@ use super::pypi_pdm::{PdmProject, PdmTarget}; use super::pypi_pipenv::{PipenvProject, PipenvTarget}; use super::pypi_poetry::{PoetryProject, PoetryTarget}; use super::pypi_requirements::{ - preflight_requirements, revert_requirements, wire_requirements, RequirementsTarget, + preflight_requirements, revert_requirements, rewire_requirements, wire_requirements, + RequirementsTarget, }; use super::pypi_uv::{ check_target_guards, load_uv_project, revert_uv, wire_uv, UvProject, UvTarget, @@ -423,6 +424,9 @@ enum WiringPlan { Uv(Box), PythonLocks(super::pypi_lock::PythonLocks), Requirements, + /// Re-wire the vendor lines an OLDER patch uuid's ledger entry recorded + /// to this uuid in place (#765). + RequirementsRewire(Box), Hatch(super::pypi_hatch::HatchProject), Poetry(Box), Pdm(Box), @@ -796,6 +800,7 @@ async fn pypi_prelude<'p>( WiringPlan::InSync } Ok(RequirementsTarget::Fresh) => WiringPlan::Requirements, + Ok(RequirementsTarget::Rewire { prev }) => WiringPlan::RequirementsRewire(prev), Err((code, detail)) => return Err(refused(code, detail)), } } @@ -1251,6 +1256,16 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( ) .await .map(|wiring| (wiring, MetaSlot::None)), + WiringPlan::RequirementsRewire(prev) => rewire_requirements( + project_root, + &prev, + &canon_name, + version, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + .map(|wiring| (wiring, MetaSlot::None)), WiringPlan::Poetry(project) => super::pypi_poetry::wire_poetry( &project, project_root, @@ -2662,12 +2677,195 @@ wheels = [ ); } - /// A requirements file already wired to an EARLIER patch uuid for the - /// same package refuses (mirrors uv/poetry): appending a second wheel - /// line would leave pip two competing requirements, and the new entry - /// would clobber the old one's ledger record, orphaning its line. + /// #765: a requirements tree already wired to an EARLIER patch uuid for + /// the same package re-vendors in place to the superseding uuid, as the + /// `would_revendor` preview and the CLI contract promise. Every recorded + /// vendor line (a rewritten root pin with a marker, a pin in a `-r` + /// include, an appended transitive line) moves to the new wheel path, + /// keeps its marker / hash mode / `(transitive)` note, and keeps the + /// pre-vendor original, so `vendor --revert` of the NEW entry restores + /// the user's files byte for byte. + #[tokio::test] + async fn requirements_superseding_uuid_revendors_in_place() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let hex = "0".repeat(64); + let shapes: Vec<(&str, Vec<(&str, String)>)> = vec![ + ("unhashed", vec![("requirements.txt", "six==1.16.0\nidna==3.7\n".into())]), + ( + "hashed, marker, continuation, CRLF", + vec![( + "requirements.txt", + format!("six==1.16.0 ; python_version >= \"3\" \\\r\n --hash=sha256:{hex}\r\nidna==3.7 --hash=sha256:{hex}\r\n"), + )], + ), + ( + "-r include", + vec![ + ("requirements.txt", "-r base.txt\nidna==3.7\n".into()), + ("base.txt", "six==1.16.0\n".into()), + ], + ), + ("transitive", vec![("requirements.txt", "idna==3.7\n".into())]), + ]; + for (shape, files) in shapes { + let fx = e2e_fixture().await; + for (name, text) in &files { + touch(&fx.root, name, text).await; + } + let sources = PatchSources::blobs_only(&fx.blobs); + let vendor_with = |record: PatchRecord| { + let sources = &sources; + let fx = &fx; + async move { + crate::vendor::test_support::vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &record, + sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await + } + }; + let VendorOutcome::Done { result, entry, .. } = vendor_with(fx.record.clone()).await + else { + panic!("{shape}: first vendor must be Done"); + }; + assert!(result.success, "{shape}: {:?}", result.error); + let first = entry.expect("entry on success"); + save_ledger_entry(&fx.root, &first).await; + + // Same package, new patch generation (different uuid). + let mut record2 = fx.record.clone(); + record2.uuid = UUID2.to_string(); + let outcome = vendor_with(record2).await; + let VendorOutcome::Done { result, entry, .. } = outcome else { + panic!("{shape}: superseding uuid must re-vendor, got {outcome:?}"); + }; + assert!(result.success, "{shape}: {:?}", result.error); + let second = entry.expect("entry on success"); + assert_eq!(second.uuid, UUID2); + assert_eq!(second.wiring.len(), first.wiring.len(), "{shape}"); + for (old, new) in first.wiring.iter().zip(&second.wiring) { + assert_eq!( + (&old.file, &old.action, &old.key, &old.original), + (&new.file, &new.action, &new.key, &new.original), + "{shape}: the pre-vendor original is carried over" + ); + let line = new.new.as_ref().and_then(|v| v.as_str()).unwrap(); + let old_line = old.new.as_ref().and_then(|v| v.as_str()).unwrap(); + assert_eq!(line, old_line.replace(UUID, UUID2), "{shape}"); + } + for (name, _) in &files { + let text = tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(); + assert!(!text.contains(UUID), "{shape}: {name} kept uuid A:\n{text}"); + } + let wired: String = { + let mut all = String::new(); + for (name, _) in &files { + all.push_str(&tokio::fs::read_to_string(fx.root.join(name)).await.unwrap()); + } + all + }; + assert_eq!( + wired.matches(UUID2).count(), + 1, + "{shape}: one six line:\n{wired}" + ); + assert!(fx + .root + .join(format!(".socket/vendor/pypi/{UUID2}/{WHEEL_NAME}")) + .is_file()); + + // Revert of the NEW entry restores every file byte for byte. + save_ledger_entry(&fx.root, &second).await; + let reverted = revert_pypi(&second, &fx.root, false).await; + assert!(reverted.success, "{shape}: {:?}", reverted.error); + assert!( + reverted.warnings.is_empty(), + "{shape}: {:?}", + reverted.warnings + ); + for (name, text) in &files { + assert_eq!( + &tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(), + text, + "{shape}: {name} restored" + ); + } + } + } + + /// #765: a re-wire replays only what the older entry's ledger recorded. A + /// vendor line edited since vendoring (no longer verbatim what the ledger + /// recorded) refuses before anything is written. + #[tokio::test] + async fn requirements_superseding_uuid_drifted_line_refuses() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let vendor_with = |record: PatchRecord| { + let sources = &sources; + let fx = &fx; + async move { + crate::vendor::test_support::vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &record, + sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await + } + }; + let VendorOutcome::Done { result, entry, .. } = vendor_with(fx.record.clone()).await else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + save_ledger_entry(&fx.root, &entry.expect("entry on success")).await; + let wired = tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(); + let drifted = wired.replace( + " # socket-patch vendor:", + " --no-deps # socket-patch vendor:", + ); + assert_ne!(drifted, wired); + touch(&fx.root, "requirements.txt", &drifted).await; + + let mut record2 = fx.record.clone(); + record2.uuid = UUID2.to_string(); + let outcome = vendor_with(record2).await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "pypi_requirements_already_vendored"); + assert!(detail.contains("changed since vendoring"), "{detail}"); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(), + drifted + ); + assert!(!fx + .root + .join(format!(".socket/vendor/pypi/{UUID2}")) + .exists()); + } + + /// #765: without a ledger entry for the older uuid there is no recorded + /// pre-vendor original to carry forward, so a re-wire could never be + /// reverted. That case still refuses, before anything is written. #[tokio::test] - async fn requirements_stale_uuid_vendor_line_refuses() { + async fn requirements_superseding_uuid_without_ledger_refuses() { const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; let fx = e2e_fixture().await; let sources = PatchSources::blobs_only(&fx.blobs); @@ -2697,7 +2895,7 @@ wheels = [ .await .unwrap(); - // Same package, new patch generation (different uuid). + // No state.json was persisted (a lost or never-committed ledger). let mut record2 = fx.record.clone(); record2.uuid = UUID2.to_string(); let outcome = vendor_with(record2).await; @@ -3492,8 +3690,13 @@ wheels = [ tokio::fs::remove_dir_all(&uuid_dir).await.unwrap(); let bytes = served_wheel(b"service wheel at another filename"); let server = wiremock::MockServer::start().await; - mount_pypi_granted(&server, "six-1.16.0-py3-none-any.whl", &sri_sha512(&bytes), &bytes) - .await; + mount_pypi_granted( + &server, + "six-1.16.0-py3-none-any.whl", + &sri_sha512(&bytes), + &bytes, + ) + .await; let cfg = pypi_service_cfg(&server.uri(), VendorSource::Service, false); let error = crate::vendor::test_support::expect_failure(vendor(Some(cfg)).await); assert!( diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 70e771ec..b9d04a21 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -130,6 +130,13 @@ pub(super) enum RequirementsTarget { /// empty and the guard checks only the path. pin: Option<(String, String)>, }, + /// The files route the package to socket-patch's own vendored wheel for + /// an OLDER patch uuid, and the ledger still holds that entry: re-wire + /// its recorded vendor lines in place to the superseding uuid + /// ([`rewire_requirements`]), carrying the pre-vendor originals over. + Rewire { + prev: Box, + }, } /// Pre-flight the wiring without writing — the orchestrator runs this before @@ -138,9 +145,12 @@ pub(super) enum RequirementsTarget { /// A file already carrying a socket vendor line for this package /// short-circuits the plan: at the SAME patch uuid it is our own first-run /// edit (in sync — the artifact-only rebuild path handles a deleted wheel); -/// at a DIFFERENT uuid it refuses — appending a second wheel line would -/// leave pip two competing requirements, and the new ledger entry would -/// clobber the old one's record, orphaning its line. +/// at a DIFFERENT uuid it is a superseding patch (#765), re-wired in place +/// when the ledger still records that older entry's wiring +/// ([`RequirementsTarget::Rewire`]). Without that record it refuses: +/// appending a second wheel line would leave pip two competing +/// requirements, and a re-wire with no recorded pre-vendor original could +/// never be reverted. pub(super) async fn preflight_requirements( root: &Path, canon_name: &str, @@ -155,14 +165,28 @@ pub(super) async fn preflight_requirements( pin: wired_pin_in(&file.content, canon_name, record_uuid), }); } - return Err(( - "pypi_requirements_already_vendored", - format!( - "{}: already routes {canon_name} to the socket-patch vendored wheel for \ - patch {found}; run `socket-patch vendor --revert` before re-vendoring", - file.rel - ), - )); + let refused = |why: &str| { + ( + "pypi_requirements_already_vendored", + format!( + "{}: already routes {canon_name} to the socket-patch vendored wheel for \ + patch {found}{why}; run `socket-patch vendor --revert` before \ + re-vendoring", + file.rel + ), + ) + }; + let Some(prev) = superseded_entry(root, canon_name, version, &found).await else { + return Err(refused( + " and the vendor ledger records no wiring for it to carry over", + )); + }; + return match plan_rewire(&files, &prev, canon_name, version, "", "") { + Ok(_) => Ok(RequirementsTarget::Rewire { + prev: Box::new(prev), + }), + Err(why) => Err(refused(&format!(" ({why})"))), + }; } } plan_requirements(root, canon_name, version, "", "") @@ -232,13 +256,56 @@ pub(super) async fn wire_requirements( wheel_sha256_hex: &str, ) -> Result, (&'static str, String)> { let plan = plan_requirements(root, canon_name, version, rel_wheel, wheel_sha256_hex).await?; + write_plan(root, &plan).await +} + +/// Re-wire the vendor lines `prev` (the ledger entry of an OLDER patch uuid +/// for this package) recorded, in place, to the superseding wheel (#765). +/// Each returned record keeps `prev`'s file, key, action and pre-vendor +/// `original`, so reverting the new entry restores the user's own pins. +pub(super) async fn rewire_requirements( + root: &Path, + prev: &VendorEntry, + canon_name: &str, + version: &str, + rel_wheel: &str, + wheel_sha256_hex: &str, +) -> Result, (&'static str, String)> { + let files = collect_requirements_files(root).await?; + let plan = plan_rewire( + &files, + prev, + canon_name, + version, + rel_wheel, + wheel_sha256_hex, + ) + .map_err(|why| { + ( + "pypi_requirements_already_vendored", + format!( + "cannot re-wire {canon_name} from patch {}: {why}; run `socket-patch vendor \ + --revert` before re-vendoring", + prev.uuid + ), + ) + })?; + write_plan(root, &plan).await +} + +/// Write a planned edit set, unwinding the files already written if any +/// write fails. Returns the wiring records in application order. +async fn write_plan( + root: &Path, + plan: &[PlannedFile], +) -> Result, (&'static str, String)> { // Before ANY write: a symlinked requirements file (root or `-r` include) // would be replaced by the rename-over. let planned: Vec<&str> = plan.iter().map(|f| f.rel.as_str()).collect(); refuse_symlinked(root, &planned, "pypi_requirements_symlink_unsupported").await?; let mut wiring = Vec::new(); let mut written: Vec<&PlannedFile> = Vec::new(); - for file in &plan { + for file in plan { if let Err(e) = atomic_write_bytes_preserving_mode(&root.join(&file.rel), file.new_content.as_bytes()) .await @@ -587,6 +654,135 @@ async fn plan_requirements( Ok(planned) } +/// The ledger entry an OLDER patch uuid left for this package's +/// requirements wiring: a pypi entry at `uuid` whose every record is a +/// `requirements_line` tagged `canon_name==version`. `None` when the ledger +/// is missing or unreadable, holds no such entry, or holds more than one +/// (ambiguous: no single set of originals to carry over). +async fn superseded_entry( + root: &Path, + canon_name: &str, + version: &str, + uuid: &str, +) -> Option { + let state = super::state::load_state_shared(root).await.ok()?; + let mut hits = state.entries.values().filter(|e| { + e.ecosystem == "pypi" + && e.uuid == uuid + && !e.wiring.is_empty() + && e.wiring.iter().all(|r| { + r.kind == "requirements_line" + && r.new + .as_ref() + .and_then(serde_json::Value::as_str) + .and_then(|line| split_comment(line).1) + .and_then(vendor_tag) + .is_some_and(|(n, v)| n == canon_name && v == version) + }) + }); + let hit = hits.next()?.clone(); + hits.next().is_none().then_some(hit) +} + +/// The environment marker a vendor line carries (`./ ; +/// [--hash=…]`, the shape [`vendor_line`] writes), from its code part. +fn vendor_line_marker(code: &str) -> Option { + let rest = code.trim().split_once(char::is_whitespace)?.1.trim_start(); + let marker = rest.strip_prefix(';')?; + let end = marker.find("--hash").unwrap_or(marker.len()); + let marker = marker[..end].trim(); + (!marker.is_empty()).then(|| marker.to_string()) +} + +/// Plan the in-place re-wire of `prev`'s recorded vendor lines to the +/// superseding wheel (#765). Pure read. Every line `prev` recorded must +/// still be present verbatim in an editable file of the tree, and they must +/// be ALL the vendor lines for the package (an unrecorded one could not be +/// reverted); otherwise the reason is returned. +fn plan_rewire( + files: &[ReqFile], + prev: &VendorEntry, + canon_name: &str, + version: &str, + rel_wheel: &str, + wheel_sha256_hex: &str, +) -> Result, String> { + let hashed = files.iter().any(|f| requires_hashes(&f.content)); + let mut order: Vec<&str> = Vec::new(); + for rec in &prev.wiring { + if !order.contains(&rec.file.as_str()) { + order.push(&rec.file); + } + } + let mut planned = Vec::new(); + let mut rewired = 0usize; + for rel in order { + let Some(file) = files.iter().find(|f| f.rel == rel) else { + return Err(format!( + "the recorded {rel} is no longer part of the requirements tree" + )); + }; + if !file.editable { + return Err(format!("{rel} is outside the project root")); + } + let nl = detect_eol(&file.content); + let mut lines: Vec = file.content.lines().map(str::to_string).collect(); + let mut taken: HashSet = HashSet::new(); + let mut records = Vec::new(); + // Bottom-up, pairing identical lines with their own records exactly + // as the revert does. + for rec in prev.wiring.iter().rev().filter(|r| r.file == rel) { + let old = rec + .new + .as_ref() + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("{rel}: a recorded vendor line is empty"))?; + let idx = (0..lines.len()) + .rev() + .find(|i| !taken.contains(i) && lines[*i].trim() == old.trim()) + .ok_or_else(|| format!("{rel}: the vendor line changed since vendoring"))?; + let marker = vendor_line_marker(split_comment(old).0); + let line = vendor_line( + rel_wheel, + hashed.then_some(wheel_sha256_hex), + canon_name, + version, + &marker, + rec.action == WiringAction::Added, + ); + lines[idx] = line.clone(); + taken.insert(idx); + records.push(WiringRecord { + new: Some(serde_json::Value::String(line)), + ..rec.clone() + }); + } + records.reverse(); // application order = top-down + rewired += records.len(); + let mut new_content = lines.join(nl); + if file.content.ends_with('\n') && !new_content.is_empty() { + new_content.push_str(nl); + } + planned.push(PlannedFile { + rel: file.rel.clone(), + original_content: file.content.clone(), + new_content, + records, + }); + } + let live: usize = files + .iter() + .map(|f| vendor_lines(&f.content, canon_name).count()) + .sum(); + if live != rewired { + return Err(format!( + "the requirements tree has {live} vendor line(s) for {canon_name}, the vendor \ + ledger records {rewired}" + )); + } + Ok(planned) +} + /// The committed vendor line. `sha256_hex` is the `--hash` pin, `None` for a /// requirements tree outside hash-checking mode (module docs). `transitive` /// adds the `(transitive)` note so a reader knows the line was appended (no