From 1e7ba6f20bbea32b72b1c3d9551aadfbe2ee65ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:26:13 +0000 Subject: [PATCH 1/3] Start fix for #1908 Assisted-by: Claude Code:claude-opus-5-5 From 512a4c4334e034a81767ec83cb912c6ae9a557d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:30:01 +0000 Subject: [PATCH 2/3] Fix vendored planning ignoring PyPI refusals scan --mode vendored chose which new patches may take a --max-new-patches slot through its own copy of the preflight checks, and that copy never ran the PyPI wiring guards. A PyPI patch the wet run refuses before downloading (pypi_no_requirements, pypi_uv_no_sources) still took a rollout slot and pushed an installable patch to the next run, while --dry-run showed the same patch as would_refuse. The planning pass now reads the dry-run preview's would-refuse rows, so the two can no longer disagree. preview_vendor takes the run's GlobalArgs and resolves the patch-server origins and gem takeover refusals itself, and scan computes it once per run, reusing it for the dry-run rendering instead of running every preflight twice. Fixes #1908 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/get.rs | 13 +- .../socket-patch-cli/src/commands/scan/mod.rs | 32 ++-- .../src/commands/scan/vendor_flow.rs | 139 +++++++++++------- 3 files changed, 100 insertions(+), 84 deletions(-) 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..3ed057dc7 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,12 @@ 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, - ) + let mut preview = match vendor_preview.take() { + Some(preview) => preview, + None => preview_vendor(&args.common, &selected).await, + }; + 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..2bd5e9373 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 the two cannot +/// drift (#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 as preview_vendor, PreviewVerdict}; use crate::json_envelope::{Command, Envelope}; use socket_patch_core::api::types::PatchSearchResult; use std::collections::HashMap; @@ -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`. From 2a26a39b735e361f1a955b37c6ef4ffd7fecdff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:43:31 +0000 Subject: [PATCH 3/3] Tidy the shared vendored preview The dry-run rendering now reuses the planning pass's preview directly instead of keeping an unreachable recompute path, the preview tests call the inner helper by its own name, and get's layering allowlist drops its import of vendor, which it no longer needs. Refs #1908 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/scan/mod.rs | 9 +++++---- .../socket-patch-cli/src/commands/scan/vendor_flow.rs | 10 +++++----- .../socket-patch-cli/tests/command_module_layering.rs | 5 ----- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 3ed057dc7..b94118412 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -3426,10 +3426,11 @@ 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 mut preview = match vendor_preview.take() { - Some(preview) => preview, - None => preview_vendor(&args.common, &selected).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 { 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 2bd5e9373..94b451426 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -214,8 +214,8 @@ impl VendorPreview { /// /// 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 the two cannot -/// drift (#1908). Resolves the run's `--patch-server-url` origins and the +/// ([`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, @@ -1394,7 +1394,7 @@ mod migration_tests { #[cfg(test)] mod preview_tests { - use super::{preview_vendor_with as 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)], &[], 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",