Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 1 addition & 12 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 15 additions & 18 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
};
Expand Down
143 changes: 87 additions & 56 deletions crates/socket-patch-cli/src/commands/scan/vendor_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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`
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -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<PatchSearchResult> = writers
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -1412,7 +1412,7 @@ mod preview_tests {
origins: &[String],
takeover: &HashMap<String, (&'static str, String)>,
) -> 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<serde_json::Value> = preview
.rows
.iter()
Expand Down Expand Up @@ -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)],
&[],
Expand Down Expand Up @@ -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`.
Expand Down
5 changes: 0 additions & 5 deletions crates/socket-patch-cli/tests/command_module_layering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading