diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 2d242dcf8..3bde4d5be 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2284,18 +2284,7 @@ async fn run_get_vendored( // Dry run: ledger-classification preview only (scan's posture) — no // download, no vendor step, no writes. if args.common.dry_run { - let takeover = super::vendor::gem_takeover_preview_refusals( - &args.common, - selected.iter().map(|p| p.purl.as_str()), - ) - .await; - let preview = super::scan::preview_vendor( - &args.common.cwd, - selected, - &super::hosted_unwind::patch_server_origins(&args.common), - &takeover, - ) - .await; + let preview = super::scan::preview_vendor(&args.common, selected).await; if args.common.json { preview.record_into(&mut env); emit(&env); diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index d9f79b2d2..b94118412 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -3297,13 +3297,17 @@ async fn run_scan( }; // The rollout plan (§5.2): deferred NEW rows leave the selection here. - // Vendored eligibility is the wet run's Bun / vlt / npm-lock / gem - // takeover preflight; the agent partition above already removed what - // cannot land in place. + // Vendored eligibility is the dry-run preview's would-refuse verdicts + // (the wet run's preflights and takeover gates), computed once and + // reused by the dry-run rendering below; the agent partition above + // already removed what cannot land in place. + let mut vendor_preview = None; let selected = if report_only { selected } else if vendor { - let refused = vendor_flow::preflight_refused_purls(&args.common, &selected).await; + let preview = preview_vendor(&args.common, &selected).await; + let refused = preview.refused_purls(); + vendor_preview = Some(preview); stage.plan(&rows, |r| !refused.contains(&r.writer.purl)); let deferred = stage.deferred_keys(); selected @@ -3422,20 +3426,13 @@ async fn run_scan( // rendered as `[would-refuse]` lines so a preview never // advertises vendoring the wet run would refuse. let preview = if vendor { - let takeover = crate::commands::vendor::gem_takeover_preview_refusals( - &args.common, - selected.iter().map(|p| p.purl.as_str()), - ) - .await; - Some( - preview_vendor( - &args.common.cwd, - &selected, - &crate::commands::hosted_unwind::patch_server_origins(&args.common), - &takeover, - ) - .await, - ) + // `vendor` is never report-only, so the planning pass + // above always computed it. + let mut preview = vendor_preview + .take() + .expect("the vendored planning pass computed the preview"); + preview.retain_selected(&selected); + Some(preview) } else { None }; diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 8c16f1962..94b451426 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -110,6 +110,30 @@ impl VendorPreview { .count() } + /// The purls the wet run would refuse: the rollout planning pass, so a + /// refused NEW patch holds no `--max-new-patches` slot (it still + /// reaches the engine, which reports the refusal). + pub(crate) fn refused_purls(&self) -> HashSet { + self.rows + .iter() + .filter(|r| matches!(r.verdict, PreviewVerdict::WouldRefuse { .. })) + .map(|r| r.purl.clone()) + .collect() + } + + /// Keep only the rows of `selected` (the patches the rollout plan left + /// in the run), so a preview computed before planning renders exactly + /// what this run would vendor. Every verdict is per patch, so a row + /// does not depend on which other patches were previewed with it. + pub(crate) fn retain_selected(&mut self, selected: &[PatchSearchResult]) { + let keep: HashSet<(&str, &str)> = selected + .iter() + .map(|p| (p.purl.as_str(), p.uuid.as_str())) + .collect(); + self.rows + .retain(|r| keep.contains(&(r.purl.as_str(), r.uuid.as_str()))); + } + /// Record the preview into a dry-run envelope, every event carrying /// `details.mode: "vendored"`: `verified` for a patch the wet run would /// vendor (`oldUuid` on a re-vendor), `skipped` / `already_vendored` @@ -178,19 +202,38 @@ impl VendorPreview { /// before any download, or whose hosted pnpm pin the takeover would fail /// to replace ([`hosted_pnpm_refusals`]: the pnpm backend's lock-text /// refusal, #853), and would-refuse for PyPI purls the PyPI backend's -/// wiring guards refuse ([`pypi_refusals`], #891). `origins` are the -/// run's `--patch-server-url` origins. The preview stays a ledger +/// wiring guards refuse ([`pypi_refusals`], #891), plus the gem +/// hosted→vendored takeover refusals +/// ([`crate::commands::vendor::gem_takeover_preview_refusals`]). The preview stays a ledger /// classification otherwise (engine refusals outside the preflights are /// not predicted), and a would-refuse never flips the run's status or exit /// code. The preflights (the only disk access besides the ledger; the PyPI /// one may also probe `pipenv --version` and list Hatch environments, as /// the wet run's guards do) run only when the selection holds an npm or /// PyPI purl. -/// `takeover_refusals` adds the hosted→vendored takeover refusals the -/// caller resolved (the gem gates of -/// [`crate::commands::vendor::gem_takeover_preview_refusals`]), keyed by -/// the selected purl, as would-refuse rows too. +/// +/// The one answer to "which selected patches would the wet run refuse?": +/// the dry-run rows and the rollout planning pass +/// ([`VendorPreview::refused_purls`]) both read it, so a patch the dry run +/// shows as would-refuse never takes a `--max-new-patches` slot (#1908). Resolves the run's `--patch-server-url` origins and the +/// gem takeover refusals itself. pub(crate) async fn preview_vendor( + common: &GlobalArgs, + selected: &[PatchSearchResult], +) -> VendorPreview { + let origins = crate::commands::hosted_unwind::patch_server_origins(common); + let takeover = crate::commands::vendor::gem_takeover_preview_refusals( + common, + selected.iter().map(|p| p.purl.as_str()), + ) + .await; + preview_vendor_with(&common.cwd, selected, &origins, &takeover).await +} + +/// [`preview_vendor`] over caller-resolved `origins` (the run's +/// `--patch-server-url` origins) and `takeover_refusals` (keyed by the +/// selected purl, each a would-refuse row); the unit tests seed both. +async fn preview_vendor_with( cwd: &Path, selected: &[PatchSearchResult], origins: &[String], @@ -341,43 +384,6 @@ pub(crate) fn merge_vendor_envelope(env: &mut Envelope, venv: Envelope) { } } -/// The purls of `selected` the wet run's Bun, vlt or npm package-lock -/// preflight, the hosted pnpm takeover's lock-text gates, or the gem -/// hosted→vendored takeover gate, would refuse before any download (the -/// `would_refuse` rows of [`preview_vendor_json`]): the vendored planning -/// pass, so a refused NEW patch holds no rollout slot. -pub(super) async fn preflight_refused_purls( - common: &GlobalArgs, - selected: &[PatchSearchResult], -) -> HashSet { - let cwd = common.cwd.as_path(); - let state = load_state(cwd).await; - let refusal = - bun_vendor_preflight_with_ledger(cwd, selected, state.as_ref().map(|s| &s.entries)).await; - let vlt_refusals = - vlt_vendor_preflight_selected(cwd, selected, state.as_ref().map(|s| &s.entries)).await; - let npm_lock_refusal = npm_lock_refusal(cwd, selected).await; - let origins = crate::commands::hosted_unwind::patch_server_origins(common); - let pnpm_refusals = - hosted_pnpm_refusals(cwd, selected, &origins, &state.unwrap_or_default()).await; - let gem_refusals = crate::commands::vendor::gem_takeover_preview_refusals( - common, - selected.iter().map(|p| p.purl.as_str()), - ) - .await; - selected - .iter() - .filter(|p| { - refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) - || vlt_refusal_for(&vlt_refusals, &p.purl).is_some() - || (p.purl.starts_with("pkg:npm/") && npm_lock_refusal.is_some()) - || pnpm_refusals.contains_key(&p.purl) - || gem_refusals.contains_key(&p.purl) - }) - .map(|p| p.purl.clone()) - .collect() -} - /// The npm package-lock backend's project-level refusal (a lock that is /// not v2/v3, see [`socket_patch_core::vendor::npm_lock_vendor_preflight`]), /// which refuses every npm purl of the project before any download or @@ -768,8 +774,8 @@ async fn run_vendor_json_path( // The planning pass: a patch the preflight refuses holds no slot (it // still reaches the engine, which reports the refusal). let writers = writers_of(&rows); - let origins = crate::commands::hosted_unwind::patch_server_origins(&args.common); - let refused = preflight_refused_purls(&args.common, &writers).await; + let mut preview = preview_vendor(&args.common, &writers).await; + let refused = preview.refused_purls(); stage.plan(&rows, |r| !refused.contains(&r.writer.purl)); let deferred = stage.deferred_keys(); let selected: Vec = writers @@ -782,14 +788,8 @@ async fn run_vendor_json_path( // No downloads, no backends: classify against the ledger // and preview the GC, exactly like agent mode's dry run (and the // human arm, which previews the same GC through `finish_human`). - let takeover = crate::commands::vendor::gem_takeover_preview_refusals( - &args.common, - selected.iter().map(|p| p.purl.as_str()), - ) - .await; - preview_vendor(&args.common.cwd, &selected, &origins, &takeover) - .await - .record_into(env); + preview.retain_selected(&selected); + preview.record_into(env); if prune { gc_into( &args.common, @@ -1394,7 +1394,7 @@ mod migration_tests { #[cfg(test)] mod preview_tests { - use super::{preview_vendor, PreviewVerdict}; + use super::{preview_vendor_with, PreviewVerdict}; use crate::json_envelope::{Command, Envelope}; use socket_patch_core::api::types::PatchSearchResult; use std::collections::HashMap; @@ -1412,7 +1412,7 @@ mod preview_tests { origins: &[String], takeover: &HashMap, ) -> serde_json::Value { - let preview = preview_vendor(cwd, selected, origins, takeover).await; + let preview = preview_vendor_with(cwd, selected, origins, takeover).await; let patches: Vec = preview .rows .iter() @@ -1450,7 +1450,7 @@ mod preview_tests { async fn preview_records_verdicts_as_vendored_events() { let tmp = tempfile::tempdir().unwrap(); seed_entry(tmp.path(), NPM, UUID); - let preview = preview_vendor( + let preview = preview_vendor_with( tmp.path(), &[sel(UUID, NPM), sel(UUID, PYPI)], &[], @@ -1918,6 +1918,37 @@ wheels = [ ); } + /// #1908: the rollout planning pass reads the same verdicts as the + /// preview, so a PyPI patch the backend refuses before any download + /// (here `pypi_uv_no_sources`) holds no `--max-new-patches` slot, while + /// the same project without the setting stays eligible. + #[tokio::test] + async fn planning_pass_excludes_a_pypi_refusal() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("uv.lock"), UV_LOCK).unwrap(); + std::fs::write( + tmp.path().join("pyproject.toml"), + format!("{UV_PYPROJECT}\n[tool.uv]\nno-sources = true\n"), + ) + .unwrap(); + let common = crate::args::GlobalArgs { + cwd: tmp.path().to_path_buf(), + silent: true, + ..Default::default() + }; + let refused = super::preview_vendor(&common, &[sel(UUID, SIX)]) + .await + .refused_purls(); + assert!(refused.contains(SIX), "{refused:?}"); + + // Control: without the setting the patch keeps its slot. + std::fs::write(tmp.path().join("pyproject.toml"), UV_PYPROJECT).unwrap(); + let refused = super::preview_vendor(&common, &[sel(UUID, SIX)]) + .await + .refused_purls(); + assert!(refused.is_empty(), "{refused:?}"); + } + /// #891: a PyPI purl with a live hosted pin is not judged by the PyPI /// guards on the still-hosted text: the wet run restores the upstream /// pin first, so the preview keeps it `would_vendor`. diff --git a/crates/socket-patch-cli/tests/command_module_layering.rs b/crates/socket-patch-cli/tests/command_module_layering.rs index bbbd24695..6c2fa78f6 100644 --- a/crates/socket-patch-cli/tests/command_module_layering.rs +++ b/crates/socket-patch-cli/tests/command_module_layering.rs @@ -51,11 +51,6 @@ const ALLOWED_COMMAND_IMPORTS: &[(&str, &str, &str)] = &[ "vex", "get's vendored leg reads VexEmbedArgs defaults", ), - ( - "get", - "vendor", - "get's vendored dry run previews vendor's gem takeover refusals", - ), // The agent download engine's nested apply still builds ApplyArgs. ( "agent_download",