From 8b37e22d4616ec395973f7bea63a54d61839f0de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:58:06 +0000 Subject: [PATCH 1/5] Start sweep of scan rollout and withdrawn Assisted-by: Claude Code:claude-opus-5-5 From ceeb0055c92430e2bc1e6eacc8c89ef3e9ec6608 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:01:40 +0000 Subject: [PATCH 2/5] Sweep scan rollout, rollout_args and withdrawn Fold human_lines and HumanContext into rollout::human, inline short_name, and replace Gate's three builder methods with a struct literal. Gate::may_admit's stage predicate moves to core Stage::may_admit. rollout_args drops its RolloutCarry and MAX_NEW_PATCHES_ENV re-exports and a parse test that tests/cli_parse_scan.rs repeats. withdrawn inlines is_gone, deletes the test that restated it, stores RunWarning, and drops bare issue numbers from its module doc. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 14 +-- .../socket-patch-cli/src/commands/scan/mod.rs | 13 +-- .../src/commands/scan/rollout.rs | 88 +++---------------- .../src/commands/scan/rollout_args.rs | 25 +----- .../src/commands/scan/withdrawn.rs | 44 +++------- crates/socket-patch-core/src/rollout/stage.rs | 10 +++ 6 files changed, 52 insertions(+), 142 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 996a9916f..27fe4a43e 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -688,11 +688,12 @@ pub(super) async fn run_redirect( &pairs, scan_result, npm_prior, - Some( - super::rollout::Gate::new(stage, rows) - .with_prior(prior) - .with_references(references), - ), + Some(super::rollout::Gate { + stage, + rows, + prior, + references, + }), "scan", ) .await @@ -1057,7 +1058,8 @@ async fn run_redirect_selected_untracked( let may_write = rollout.as_ref().is_none_or(|gate| { let new = gate.new_keys(); candidates.iter().any(|c| { - !new.contains(&(sel_purl(c), c.dep.patch_uuid.clone())) || gate.may_admit(&sel_purl(c)) + !new.contains(&(sel_purl(c), c.dep.patch_uuid.clone())) + || gate.stage.may_admit(&sel_purl(c)) }) }); let mut lock: Option = if !common.dry_run && !candidates.is_empty() && may_write { diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index d9f79b2d2..2da12996c 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1676,7 +1676,7 @@ async fn run_project_dirs( Err(message) => return usage("invalid_env", &message), }; let root = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone()); - let carry = rollout_args::RolloutCarry::new(configured, root); + let carry = rollout::RolloutCarry::new(configured, root); let mut code = 0; for (dir, explicit) in &dirs { if dirs.len() > 1 && !args.common.silent { @@ -3258,11 +3258,12 @@ async fn run_scan( &pairs, None, npm_crawl.as_ref(), - Some( - rollout::Gate::new(&mut stage, rows) - .with_prior(prior_discovery) - .with_references(sweep_references), - ), + Some(rollout::Gate { + stage: &mut stage, + rows, + prior: prior_discovery, + references: sweep_references, + }), "scan", ) .await; diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs index b5c37ac6c..4c425c7e3 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; pub(crate) use socket_patch_core::rollout::stage::*; -use socket_patch_core::rollout::{severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan}; +use socket_patch_core::rollout::{severity_label, MaxNewSource, Recorded}; use socket_patch_core::utils::purl_key::PurlKey; use super::discovery::UpdateInfo; @@ -73,32 +73,7 @@ impl<'a> Prior<'a> { } } -impl<'a> Gate<'a> { - pub(crate) fn new(stage: &'a mut Stage, rows: Vec) -> Self { - Gate { - stage, - rows, - prior: None, - references: None, - } - } - - /// This gate carrying scan's pre-redirect discovery (see [`Self::prior`]). - pub(crate) fn with_prior(mut self, prior: Option>) -> Self { - self.prior = prior; - self - } - - /// This gate carrying the sweep's reference answers (see - /// [`Self::references`]). - pub(crate) fn with_references( - mut self, - references: Option, - ) -> Self { - self.references = references; - self - } - +impl Gate<'_> { /// `(purl, uuid)` of every NEW row, for O(1) [`Self::is_new`] checks. pub(crate) fn new_keys(&self) -> HashSet<(String, String)> { self.rows @@ -107,17 +82,6 @@ impl<'a> Gate<'a> { .map(|r| (r.writer.purl.clone(), r.writer.uuid.clone())) .collect() } - - /// Whether a NEW `(purl, uuid)` row could be admitted: budget left, or - /// its package already admitted by an earlier directory. - pub(crate) fn may_admit(&self, purl: &str) -> bool { - self.stage.may_admit_new() - || (!(self.stage.capped() && self.stage.incomplete) - && self - .stage - .already_admitted - .contains(&PurlKey::new(purl).into_string())) - } } /// `updates[]` for a run that fetched by-package records: the UPGRADE rows, @@ -151,45 +115,15 @@ pub(crate) fn human(stage: &Stage, dry_run: bool) -> (Option, Vec &str { - base_purl - .strip_prefix("pkg:") - .and_then(|rest| rest.split_once('/')) - .map_or(base_purl, |(_, name)| name) -} - -/// How [`human_lines`] words a run. -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct HumanContext { - pub(crate) dry_run: bool, - /// A lookup failed, so no new patch could be admitted. - pub(crate) incomplete: bool, - /// The budget is shared with other project directories of this run. - pub(crate) shared: bool, -} - -pub(crate) fn human_lines( - configured: &MaxNew, - plan: &RolloutPlan, - ctx: HumanContext, -) -> (Option, Vec) { + let configured = &stage.configured; let c = plan.counts; let line = configured.value.map(|cap| { - let verb = if ctx.dry_run { + let verb = if dry_run { "would be applied" } else { "applied" }; - let shared = match (ctx.shared, plan.remaining) { + let shared = match (stage.carry.is_some(), plan.remaining) { (true, Some(left)) => format!(", shared by this run's directories, {left} left"), _ => String::new(), }; @@ -207,13 +141,13 @@ pub(crate) fn human_lines( return (line, Vec::new()); } let deferred = crate::ui::plural(groups.len(), "new patch", "new patches"); - let deferred = if ctx.dry_run { + let deferred = if dry_run { format!("{deferred} would be deferred") } else { format!("{deferred} deferred") }; let first = match configured.value { - _ if ctx.incomplete => format!( + _ if stage.incomplete && stage.capped() => format!( "{deferred}: a patch lookup failed, so no new patch was added this run; run scan \ again once the patch API answers." ), @@ -226,7 +160,7 @@ pub(crate) fn human_lines( MaxNewSource::Cap | MaxNewSource::Default => "raise the cap", } ), - Some(cap) if ctx.dry_run => format!( + Some(cap) if dry_run => format!( "{deferred}; the wet run adds the top {}, and each later committed scan the next ones.", (cap as usize).min(c.new as usize + groups.len()) ), @@ -242,7 +176,10 @@ pub(crate) fn human_lines( .map(|g| { format!( "{} ({})", - short_name(&g.base_purl), + g.base_purl + .strip_prefix("pkg:") + .and_then(|rest| rest.split_once('/')) + .map_or(g.base_purl.as_str(), |(_, name)| name), severity_label(g.severity_order) ) }) @@ -307,6 +244,7 @@ mod tests { use socket_patch_core::api::types::VulnerabilityResponse; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::manifest::schema::PatchRecord; + use socket_patch_core::rollout::MaxNew; use std::collections::HashMap; use std::path::Path; diff --git a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs index f83636045..5bcedf86e 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs @@ -2,14 +2,9 @@ //! `docs/configuration.md#gradual-rollout`). use clap::Args; -pub(crate) use socket_patch_core::rollout::stage::RolloutCarry; +use socket_patch_core::rollout::stage::{RolloutCarry, MAX_NEW_PATCHES_ENV}; use socket_patch_core::rollout::{resolve_max_new, MaxNew}; -/// The env binding of `--max-new-patches`. Read by [`RolloutArgs::resolve`] -/// rather than clap's `env =`, because the rollout block reports whether -/// the value came from the flag or the environment. -pub use socket_patch_core::rollout::stage::MAX_NEW_PATCHES_ENV; - /// A parsed `--max-new-patches` value; `None` is `none` (no cap). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MaxNewPatches(pub Option); @@ -49,7 +44,9 @@ pub struct RolloutArgs { impl RolloutArgs { /// The configured cap: the flag, then `env` (the /// [`MAX_NEW_PATCHES_ENV`] value; empty is unset), then the socket.yml - /// value, then unlimited. A malformed env value is a usage error. + /// value, then unlimited. A malformed env value is a usage error. The + /// env is read here, not by clap's `env =`, because the rollout block + /// reports whether the value came from the flag or the environment. pub fn resolve(&self, env: Option<&str>, file: Option) -> Result { // The flag wins outright: an env value it overrides is never read. let env = env.filter(|_| self.max_new_patches.is_none()); @@ -81,20 +78,6 @@ mod tests { use super::*; use socket_patch_core::rollout::MaxNewSource; - #[test] - fn parses_numbers_and_none() { - assert_eq!(parse_max_new_patches("5"), Ok(MaxNewPatches(Some(5)))); - assert_eq!(parse_max_new_patches("0"), Ok(MaxNewPatches(Some(0)))); - assert_eq!( - parse_max_new_patches("4294967295"), - Ok(MaxNewPatches(Some(u32::MAX))) - ); - assert_eq!(parse_max_new_patches("NONE"), Ok(MaxNewPatches(None))); - for bad in ["", "-1", "4294967296", "five", "1.5", "all"] { - assert!(parse_max_new_patches(bad).is_err(), "{bad:?}"); - } - } - #[test] fn flag_beats_env_beats_file() { let flag = RolloutArgs { diff --git a/crates/socket-patch-cli/src/commands/scan/withdrawn.rs b/crates/socket-patch-cli/src/commands/scan/withdrawn.rs index 045d64951..c1302f1b8 100644 --- a/crates/socket-patch-cli/src/commands/scan/withdrawn.rs +++ b/crates/socket-patch-cli/src/commands/scan/withdrawn.rs @@ -26,8 +26,7 @@ //! //! The settled answers are kept ([`WithdrawnSweep::take_references`]): on //! a rescan the redirect selects the same uuids the lockfiles pin, and asks -//! the endpoint only about the ones the sweep has no answer for (#1606, -//! #1607). +//! the endpoint only about the ones the sweep has no answer for. use std::collections::{BTreeSet, HashMap}; use std::time::Duration; @@ -39,7 +38,7 @@ use socket_patch_core::utils::purl_key::PurlKey; use crate::args::GlobalArgs; use crate::commands::agent_download::tag_mode; -use crate::json_envelope::{Envelope, PatchAction, PatchEvent}; +use crate::json_envelope::{Envelope, PatchAction, PatchEvent, RunWarning}; /// `reason` code on the `rolledBack` event of a pin the sweep restored. pub(super) const PATCH_WITHDRAWN: &str = "patch_withdrawn"; @@ -53,19 +52,13 @@ pub(super) const REDIRECT_WITHDRAWN_RESTORE_FAILED: &str = "redirect_withdrawn_r /// could not tell whether any pin was withdrawn. Every pin is kept. pub(super) const REDIRECT_WITHDRAWN_CHECK_FAILED: &str = "redirect_withdrawn_check_failed"; -/// Whether a reference `status` means the pinned patch is gone for good. -fn is_gone(status: &str) -> bool { - matches!(status, "withdrawn" | "not_found") -} - /// What the sweep did, for the `--json` envelope. Human output is printed /// as the sweep runs. #[derive(Debug, Default)] pub(super) struct WithdrawnSweep { /// `rolledBack` (`verified` on a dry run) per restored pin. events: Vec, - /// `(code, detail)` run-level warnings. - warnings: Vec<(String, String)>, + warnings: Vec, /// The reference endpoint's answers, when the lookup succeeded. references: Option, } @@ -118,9 +111,7 @@ impl WithdrawnSweep { for event in self.events { env.record(event); } - for (code, detail) in self.warnings { - env.warn(code, detail); - } + env.warnings.extend(self.warnings); } fn warn(&mut self, common: &GlobalArgs, code: &str, detail: String) { @@ -129,7 +120,7 @@ impl WithdrawnSweep { if !common.json { eprintln!("Warning: {detail}"); } - self.warnings.push((code.to_string(), detail)); + self.warnings.push(RunWarning::new(code, detail)); } } @@ -187,7 +178,8 @@ pub(super) async fn sweep( .iter() .filter_map(|pin| { let status = &references.get(&pin.uuid)?.status; - is_gone(status).then(|| (pin.clone(), status.clone())) + matches!(status.as_str(), "withdrawn" | "not_found") + .then(|| (pin.clone(), status.clone())) }) .collect(); out.references = Some(KnownReferences::settled(&uuids, references)); @@ -264,8 +256,8 @@ pub(super) async fn sweep( } // `run_hosted_leg` already printed each failure to stderr. for (purl, why) in leg.failed { - out.warnings.push(( - REDIRECT_WITHDRAWN_RESTORE_FAILED.to_string(), + out.warnings.push(RunWarning::new( + REDIRECT_WITHDRAWN_RESTORE_FAILED, format!("{purl}: {why}"), )); } @@ -273,7 +265,7 @@ pub(super) async fn sweep( if !common.json && !common.silent { eprintln!("Warning: {detail}"); } - out.warnings.push((code, detail)); + out.warnings.push(RunWarning::new(code, detail)); } out } @@ -290,22 +282,6 @@ mod tests { } } - #[test] - fn only_withdrawn_and_not_found_are_gone() { - assert!(is_gone("withdrawn")); - assert!(is_gone("not_found")); - for kept in [ - "granted", - "reused", - "pending_build", - "build_failed", - "forbidden", - "", - ] { - assert!(!is_gone(kept), "{kept} must keep the pin"); - } - } - #[test] fn scope_matches_by_release_identity() { let pins = vec![ diff --git a/crates/socket-patch-core/src/rollout/stage.rs b/crates/socket-patch-core/src/rollout/stage.rs index bbdf96e05..2cc955217 100644 --- a/crates/socket-patch-core/src/rollout/stage.rs +++ b/crates/socket-patch-core/src/rollout/stage.rs @@ -361,6 +361,16 @@ impl Stage { !(self.capped() && self.incomplete) && self.budget.value != Some(0) } + /// Whether a NEW row of `purl` could be admitted: budget left, or its + /// package already admitted by an earlier directory. + pub fn may_admit(&self, purl: &str) -> bool { + self.may_admit_new() + || (!(self.capped() && self.incomplete) + && self + .already_admitted + .contains(&PurlKey::new(purl).into_string())) + } + /// Plan `rows` with `eligible` deciding each NEW row, record the plan /// and hand the remaining budget to the next directory. pub fn plan(&mut self, rows: &[Row], eligible: impl Fn(&Row) -> bool) -> &RolloutPlan { From 9e6344234e0bf06610903fb429a3abc14b4fa31c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:05:11 +0000 Subject: [PATCH 3/5] Move core-only rollout tests to core stage.rs Eleven scan/rollout.rs tests exercised only core rollout::stage (classify, mark_pinned, offers_from_results, lookup_incomplete, Stage warnings). They now live beside that code, which had no tests. The tests that check the CLI's human lines and updates[] stay. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/rollout.rs | 317 --------------- crates/socket-patch-core/src/rollout/stage.rs | 369 ++++++++++++++++++ 2 files changed, 369 insertions(+), 317 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs index 4c425c7e3..c696d1e0d 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -305,22 +305,6 @@ mod tests { .collect() } - #[test] - fn offers_drop_inaccessible_paid_patches_and_pick_the_top_one() { - let mut paid = offer("pkg:npm/a@1", "p", "2026-01-01T00:00:00Z", &["critical"]); - paid.tier = "paid".into(); - let results = vec![ - offer("pkg:npm/a@1", "old", "2020-01-01T00:00:00Z", &["high"]), - paid, - offer("pkg:npm/a@1", "new", "2026-01-01T00:00:00Z", &["high"]), - ]; - let free = offers_from_results(&results, false); - assert_eq!(free.selected["pkg:npm/a@1"].uuid, "new"); - assert_eq!(free.unfiltered["pkg:npm/a@1"].len(), 2); - let all = offers_from_results(&results, true); - assert_eq!(all.selected["pkg:npm/a@1"].uuid, "p"); - } - #[test] fn classification_rows() { let results = vec![ @@ -386,213 +370,6 @@ mod tests { ); } - #[test] - fn composer_version_spellings_upgrade_without_spending_the_new_patch_budget() { - let stored = manifest(&[("pkg:composer/psr/log@3.0.2.0", "old")]); - let recorded = RecordedIndex::new(Some(&stored), &[]); - let offers = offers_from_results( - &[offer( - "pkg:composer/psr/log@v3.0.2", - "new", - "2026-02-01T00:00:00Z", - &["high"], - )], - false, - ); - let rows = classify(&offers, &recorded, ""); - let plan = socket_patch_core::rollout::plan_rollout( - rows.into_iter().map(|row| row.candidate).collect(), - &MaxNew { - value: Some(0), - source: MaxNewSource::Flag, - }, - false, - &BTreeSet::new(), - ); - assert_eq!(plan.counts.upgrade, 1); - assert_eq!(plan.counts.new, 0); - assert!(plan.deferred.is_empty()); - assert_eq!(plan.admitted.len(), 1); - } - - #[test] - fn recorded_matches_percent_encoding_and_qualifier_twins() { - let results = vec![ - offer("pkg:npm/%40s/x@1", "e1", "", &["high"]), - offer("pkg:pypi/w@1?artifact_id=b", "w2", "", &["high"]), - ]; - let offers = offers_from_results(&results, true); - let recorded = manifest(&[ - ("pkg:npm/@s/x@1", "e1"), - ("pkg:pypi/w@1?artifact_id=a", "w1"), - ]); - let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); - assert_eq!(rows[0].candidate.recorded, Recorded::Same); - // The twin's recorded uuid is not offered for this twin: the late - // twin lands uncapped as an UPGRADE. - assert_eq!( - rows[1].candidate.recorded, - Recorded::Superseded { - old_uuid: "w1".into() - } - ); - } - - #[test] - fn several_recorded_uuids_prefer_the_selected_one_else_the_smallest() { - let results = vec![offer("pkg:pypi/w@1", "b", "", &["high"])]; - let offers = offers_from_results(&results, true); - let recorded = manifest(&[ - ("pkg:pypi/w@1?artifact_id=1", "c"), - ("pkg:pypi/w@1?artifact_id=2", "b"), - ]); - let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); - assert_eq!(rows[0].candidate.recorded, Recorded::Same); - let recorded = manifest(&[ - ("pkg:pypi/w@1?artifact_id=1", "d"), - ("pkg:pypi/w@1?artifact_id=2", "c"), - ]); - let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); - assert_eq!( - rows[0].candidate.recorded, - Recorded::Superseded { - old_uuid: "c".into() - } - ); - } - - #[test] - fn a_discovered_pin_of_the_selected_uuid_marks_the_row_already() { - let results = vec![ - offer( - "pkg:npm/a@1", - "aaaaaaaa-1111-4111-8111-00000000000a", - "", - &["high"], - ), - offer( - "pkg:npm/b@1", - "bbbbbbbb-1111-4111-8111-00000000000b", - "", - &["high"], - ), - ]; - let offers = offers_from_results(&results, true); - let mut rows = classify(&offers, &RecordedIndex::default(), ""); - mark_pinned( - &mut rows, - &[socket_patch_core::patch::redirect::upstream::HostedPin { - purl: "pkg:npm/a@1".into(), - uuid: "aaaaaaaa-1111-4111-8111-00000000000a".into(), - files: vec!["package-lock.json".into()], - }], - ); - assert_eq!(rows[0].candidate.recorded, Recorded::Same); - assert_eq!(rows[1].candidate.recorded, Recorded::None); - } - - #[test] - fn a_discovered_pin_of_another_uuid_marks_the_row_an_upgrade() { - // A pin on a patch server only this run's references name is found - // by the second discovery pass: an older patch pinned there is an - // UPGRADE, not a NEW row spending a cap slot. - let results = vec![offer( - "pkg:npm/a@1", - "aaaaaaaa-1111-4111-8111-00000000000a", - "", - &["high"], - )]; - let offers = offers_from_results(&results, true); - let mut rows = classify(&offers, &RecordedIndex::default(), ""); - mark_pinned( - &mut rows, - &[socket_patch_core::patch::redirect::upstream::HostedPin { - purl: "pkg:npm/a@1".into(), - uuid: "AAAAAAAA-2222-4222-8222-00000000000A".into(), - files: vec!["package-lock.json".into()], - }], - ); - assert_eq!( - rows[0].candidate.recorded, - Recorded::Superseded { - old_uuid: "aaaaaaaa-2222-4222-8222-00000000000a".into() - } - ); - } - - #[test] - fn case_folded_pins_match_the_selection_spelling() { - // Discovery keys a nuget pin by the lowercased name; the API and - // the lockfile keep the original case. - let results = vec![ - offer("pkg:nuget/Newtonsoft.Json@13.0.3", "a-sel", "", &["high"]), - offer("pkg:nuget/Newtonsoft.Json@13.0.3", "z-pin", "", &["high"]), - ]; - let offers = offers_from_results(&results, true); - let index = RecordedIndex::new( - None, - &[("pkg:nuget/newtonsoft.json@13.0.3".into(), "z-pin".into())], - ); - let rows = classify(&offers, &index, ""); - assert_eq!( - rows[0].candidate.recorded, - Recorded::Kept { - uuid: "z-pin".into() - }, - "an equal sibling never replaces the pinned patch" - ); - assert_eq!(rows[0].writer.uuid, "z-pin"); - } - - #[test] - fn both_pinned_qualifier_twins_are_already() { - // Hosted pins are keyed by base purl; each twin carries its own - // patch. Neither may read as an UPGRADE on a converged repo. - let results = vec![ - offer( - "pkg:pypi/foo@1.0?artifact_id=sdist", - "u-sdist", - "", - &["high"], - ), - offer("pkg:pypi/foo@1.0?artifact_id=whl", "u-whl", "", &["high"]), - ]; - let offers = offers_from_results(&results, true); - let pins = vec![ - ("pkg:pypi/foo@1.0".to_string(), "u-sdist".to_string()), - ("pkg:pypi/foo@1.0".to_string(), "u-whl".to_string()), - ]; - let mut merged = PatchManifest::new(); - merged.patches.insert( - "pkg:pypi/foo@1.0".into(), - manifest(&[("x", "u-sdist")]).patches.remove("x").unwrap(), - ); - let rows = classify(&offers, &RecordedIndex::new(Some(&merged), &pins), ""); - assert!( - rows.iter().all(|r| r.candidate.recorded == Recorded::Same), - "{rows:?}" - ); - } - - #[test] - fn lookup_incomplete_only_when_a_new_package_could_be_missing() { - let index = RecordedIndex::new(Some(&manifest(&[("pkg:npm/rec@1", "u")])), &[]); - assert!(!lookup_incomplete(&index, &[], false)); - assert!( - lookup_incomplete(&index, &[], true), - "a failed batch hides unknown packages" - ); - assert!( - !lookup_incomplete(&index, &["pkg:npm/rec@1".into()], false), - "a recorded package's failed lookup cannot hide a NEW row" - ); - assert!(lookup_incomplete( - &index, - &["pkg:npm/other@1".into()], - false - )); - } - #[test] fn stage_carries_the_budget_and_renders_the_block() { let configured = MaxNew { @@ -701,98 +478,4 @@ mod tests { ); assert_eq!(next[1], "Next up: a@1 (high), b@1 (high), c@1 (high), …"); } - - #[test] - fn incomplete_lookups_warn_only_when_they_deferred_something() { - let results = vec![offer("pkg:npm/a@1", "ua", "", &["critical"])]; - let offers = offers_from_results(&results, true); - let capped = MaxNew { - value: Some(3), - source: MaxNewSource::Flag, - }; - let mut stage = Stage::new(capped, None, Path::new("/repo")); - stage.incomplete = true; - assert!(!stage.may_admit_new()); - stage.plan(&classify(&offers, &RecordedIndex::default(), ""), |_| true); - let codes: Vec<&str> = stage.warnings().iter().map(|(c, _)| *c).collect(); - assert_eq!(codes, [ROLLOUT_INCOMPLETE_LOOKUP]); - let mut unlimited = Stage::new(MaxNew::UNLIMITED, None, Path::new("/repo")); - unlimited.incomplete = true; - assert!(unlimited.may_admit_new()); - unlimited.plan(&classify(&offers, &RecordedIndex::default(), ""), |_| true); - assert!(unlimited.warnings().is_empty()); - } - - /// REGRESSION: a lockless NuGet / Cargo pin (the hosted rewriter's own - /// output when the project has no lockfile) is never a discovery ref, - /// so `HostedPin::all` drops it. Re-scanned under `--max-new-patches 1` - /// it must read as ALREADY through the recorded view (as `scan` builds - /// it), not as NEW: otherwise every re-scan spends the one slot on the - /// pin it already wrote and the genuinely new patch is deferred forever. - #[tokio::test] - async fn a_rescanned_lockless_pin_is_already_not_new_under_a_cap() { - use socket_patch_core::patch::redirect::upstream::HostedPin; - const NUGET: &str = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa"; - const CARGO: &str = "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb"; - const GRANT: &str = "cccccccc-3333-4333-8333-cccccccccccc"; - let tmp = tempfile::tempdir().unwrap(); - let nuget_key = format!("socket-patch-{NUGET}"); - std::fs::write( - tmp.path().join("nuget.config"), - format!( - "\n\n \n \ - \n \ - \n \n \n \ - \n \n \ - \n\n" - ), - ) - .unwrap(); - std::fs::write( - tmp.path().join("Cargo.toml"), - format!( - "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ - serde = {{ version = \"1\", registry = \"socket-patch-{CARGO}\" }}\n" - ), - ) - .unwrap(); - std::fs::create_dir(tmp.path().join(".cargo")).unwrap(); - std::fs::write( - tmp.path().join(".cargo/config.toml"), - format!( - "[registries.socket-patch-{CARGO}]\nindex = \ - \"sparse+https://patch.socket.dev/patch-registry/cargo/{GRANT}/{CARGO}/index/\"\n" - ), - ) - .unwrap(); - let discovery = socket_patch_core::vex::discover::discover_patched_refs(tmp.path()).await; - assert_eq!(discovery.unlocked_pins.len(), 2, "{discovery:#?}"); - let pins: Vec<(String, String)> = HostedPin::all(&discovery) - .into_iter() - .map(|p| (p.purl, p.uuid)) - .collect(); - assert!(pins.is_empty(), "lockless pins are never refs: {pins:?}"); - - let results = vec![ - offer("pkg:nuget/Newtonsoft.Json@13.0.3", NUGET, "", &["critical"]), - offer("pkg:cargo/serde@1.0.200", CARGO, "", &["critical"]), - offer("pkg:npm/fresh@1.0.0", "fresh", "", &["low"]), - ]; - let offers = offers_from_results(&results, true); - let index = RecordedIndex::new(None, &pins).with_unlocked_pins(discovery.unlocked_pins); - let one = MaxNew { - value: Some(1), - source: MaxNewSource::Flag, - }; - let mut stage = Stage::new(one, None, Path::new("/repo")); - stage.plan(&classify(&offers, &index, ""), |_| true); - let counts = &stage.json()["counts"]; - assert_eq!(counts["already"], 2, "{counts}"); - assert_eq!(counts["new"], 1, "{counts}"); - assert_eq!(counts["deferred"], 0, "{counts}"); - - // A version outside the cargo pin's requirement is not routed by it. - assert!(index.uuids("pkg:cargo/serde@2.0.0").is_empty()); - assert!(!index.records_package("pkg:cargo/serde@2.0.0")); - } } diff --git a/crates/socket-patch-core/src/rollout/stage.rs b/crates/socket-patch-core/src/rollout/stage.rs index 2cc955217..5d4c423da 100644 --- a/crates/socket-patch-core/src/rollout/stage.rs +++ b/crates/socket-patch-core/src/rollout/stage.rs @@ -538,3 +538,372 @@ pub fn rollout_json(configured: &MaxNew, plan: Option<&RolloutPlan>) -> serde_js "deferred": deferred, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::types::VulnerabilityResponse; + use crate::manifest::schema::PatchRecord; + use crate::patch::redirect::upstream::HostedPin; + + fn offer(purl: &str, uuid: &str, published: &str, severities: &[&str]) -> PatchSearchResult { + PatchSearchResult { + uuid: uuid.to_string(), + purl: purl.to_string(), + published_at: published.to_string(), + description: String::new(), + license: "MIT".to_string(), + tier: "free".to_string(), + vulnerabilities: severities + .iter() + .enumerate() + .map(|(i, s)| { + ( + format!("GHSA-{uuid}-{i}"), + VulnerabilityResponse { + cves: Vec::new(), + summary: String::new(), + severity: (*s).to_string(), + description: String::new(), + }, + ) + }) + .collect(), + } + } + + fn manifest(entries: &[(&str, &str)]) -> PatchManifest { + let mut m = PatchManifest::new(); + for (purl, uuid) in entries { + m.patches.insert( + (*purl).to_string(), + PatchRecord { + uuid: (*uuid).to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }, + ); + } + m + } + + #[test] + fn offers_drop_inaccessible_paid_patches_and_pick_the_top_one() { + let mut paid = offer("pkg:npm/a@1", "p", "2026-01-01T00:00:00Z", &["critical"]); + paid.tier = "paid".into(); + let results = vec![ + offer("pkg:npm/a@1", "old", "2020-01-01T00:00:00Z", &["high"]), + paid, + offer("pkg:npm/a@1", "new", "2026-01-01T00:00:00Z", &["high"]), + ]; + let free = offers_from_results(&results, false); + assert_eq!(free.selected["pkg:npm/a@1"].uuid, "new"); + assert_eq!(free.unfiltered["pkg:npm/a@1"].len(), 2); + let all = offers_from_results(&results, true); + assert_eq!(all.selected["pkg:npm/a@1"].uuid, "p"); + } + + #[test] + fn composer_version_spellings_upgrade_without_spending_the_new_patch_budget() { + let stored = manifest(&[("pkg:composer/psr/log@3.0.2.0", "old")]); + let recorded = RecordedIndex::new(Some(&stored), &[]); + let offers = offers_from_results( + &[offer( + "pkg:composer/psr/log@v3.0.2", + "new", + "2026-02-01T00:00:00Z", + &["high"], + )], + false, + ); + let rows = classify(&offers, &recorded, ""); + let plan = crate::rollout::plan_rollout( + rows.into_iter().map(|row| row.candidate).collect(), + &MaxNew { + value: Some(0), + source: MaxNewSource::Flag, + }, + false, + &BTreeSet::new(), + ); + assert_eq!(plan.counts.upgrade, 1); + assert_eq!(plan.counts.new, 0); + assert!(plan.deferred.is_empty()); + assert_eq!(plan.admitted.len(), 1); + } + + #[test] + fn recorded_matches_percent_encoding_and_qualifier_twins() { + let results = vec![ + offer("pkg:npm/%40s/x@1", "e1", "", &["high"]), + offer("pkg:pypi/w@1?artifact_id=b", "w2", "", &["high"]), + ]; + let offers = offers_from_results(&results, true); + let recorded = manifest(&[ + ("pkg:npm/@s/x@1", "e1"), + ("pkg:pypi/w@1?artifact_id=a", "w1"), + ]); + let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); + assert_eq!(rows[0].candidate.recorded, Recorded::Same); + // The twin's recorded uuid is not offered for this twin: the late + // twin lands uncapped as an UPGRADE. + assert_eq!( + rows[1].candidate.recorded, + Recorded::Superseded { + old_uuid: "w1".into() + } + ); + } + + #[test] + fn several_recorded_uuids_prefer_the_selected_one_else_the_smallest() { + let results = vec![offer("pkg:pypi/w@1", "b", "", &["high"])]; + let offers = offers_from_results(&results, true); + let recorded = manifest(&[ + ("pkg:pypi/w@1?artifact_id=1", "c"), + ("pkg:pypi/w@1?artifact_id=2", "b"), + ]); + let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); + assert_eq!(rows[0].candidate.recorded, Recorded::Same); + let recorded = manifest(&[ + ("pkg:pypi/w@1?artifact_id=1", "d"), + ("pkg:pypi/w@1?artifact_id=2", "c"), + ]); + let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); + assert_eq!( + rows[0].candidate.recorded, + Recorded::Superseded { + old_uuid: "c".into() + } + ); + } + + #[test] + fn a_discovered_pin_of_the_selected_uuid_marks_the_row_already() { + let results = vec![ + offer( + "pkg:npm/a@1", + "aaaaaaaa-1111-4111-8111-00000000000a", + "", + &["high"], + ), + offer( + "pkg:npm/b@1", + "bbbbbbbb-1111-4111-8111-00000000000b", + "", + &["high"], + ), + ]; + let offers = offers_from_results(&results, true); + let mut rows = classify(&offers, &RecordedIndex::default(), ""); + mark_pinned( + &mut rows, + &[HostedPin { + purl: "pkg:npm/a@1".into(), + uuid: "aaaaaaaa-1111-4111-8111-00000000000a".into(), + files: vec!["package-lock.json".into()], + }], + ); + assert_eq!(rows[0].candidate.recorded, Recorded::Same); + assert_eq!(rows[1].candidate.recorded, Recorded::None); + } + + #[test] + fn a_discovered_pin_of_another_uuid_marks_the_row_an_upgrade() { + // A pin on a patch server only this run's references name is found + // by the second discovery pass: an older patch pinned there is an + // UPGRADE, not a NEW row spending a cap slot. + let results = vec![offer( + "pkg:npm/a@1", + "aaaaaaaa-1111-4111-8111-00000000000a", + "", + &["high"], + )]; + let offers = offers_from_results(&results, true); + let mut rows = classify(&offers, &RecordedIndex::default(), ""); + mark_pinned( + &mut rows, + &[HostedPin { + purl: "pkg:npm/a@1".into(), + uuid: "AAAAAAAA-2222-4222-8222-00000000000A".into(), + files: vec!["package-lock.json".into()], + }], + ); + assert_eq!( + rows[0].candidate.recorded, + Recorded::Superseded { + old_uuid: "aaaaaaaa-2222-4222-8222-00000000000a".into() + } + ); + } + + #[test] + fn case_folded_pins_match_the_selection_spelling() { + // Discovery keys a nuget pin by the lowercased name; the API and + // the lockfile keep the original case. + let results = vec![ + offer("pkg:nuget/Newtonsoft.Json@13.0.3", "a-sel", "", &["high"]), + offer("pkg:nuget/Newtonsoft.Json@13.0.3", "z-pin", "", &["high"]), + ]; + let offers = offers_from_results(&results, true); + let index = RecordedIndex::new( + None, + &[("pkg:nuget/newtonsoft.json@13.0.3".into(), "z-pin".into())], + ); + let rows = classify(&offers, &index, ""); + assert_eq!( + rows[0].candidate.recorded, + Recorded::Kept { + uuid: "z-pin".into() + }, + "an equal sibling never replaces the pinned patch" + ); + assert_eq!(rows[0].writer.uuid, "z-pin"); + } + + #[test] + fn both_pinned_qualifier_twins_are_already() { + // Hosted pins are keyed by base purl; each twin carries its own + // patch. Neither may read as an UPGRADE on a converged repo. + let results = vec![ + offer( + "pkg:pypi/foo@1.0?artifact_id=sdist", + "u-sdist", + "", + &["high"], + ), + offer("pkg:pypi/foo@1.0?artifact_id=whl", "u-whl", "", &["high"]), + ]; + let offers = offers_from_results(&results, true); + let pins = vec![ + ("pkg:pypi/foo@1.0".to_string(), "u-sdist".to_string()), + ("pkg:pypi/foo@1.0".to_string(), "u-whl".to_string()), + ]; + let mut merged = PatchManifest::new(); + merged.patches.insert( + "pkg:pypi/foo@1.0".into(), + manifest(&[("x", "u-sdist")]).patches.remove("x").unwrap(), + ); + let rows = classify(&offers, &RecordedIndex::new(Some(&merged), &pins), ""); + assert!( + rows.iter().all(|r| r.candidate.recorded == Recorded::Same), + "{rows:?}" + ); + } + + #[test] + fn lookup_incomplete_only_when_a_new_package_could_be_missing() { + let index = RecordedIndex::new(Some(&manifest(&[("pkg:npm/rec@1", "u")])), &[]); + assert!(!lookup_incomplete(&index, &[], false)); + assert!( + lookup_incomplete(&index, &[], true), + "a failed batch hides unknown packages" + ); + assert!( + !lookup_incomplete(&index, &["pkg:npm/rec@1".into()], false), + "a recorded package's failed lookup cannot hide a NEW row" + ); + assert!(lookup_incomplete( + &index, + &["pkg:npm/other@1".into()], + false + )); + } + + #[test] + fn incomplete_lookups_warn_only_when_they_deferred_something() { + let results = vec![offer("pkg:npm/a@1", "ua", "", &["critical"])]; + let offers = offers_from_results(&results, true); + let capped = MaxNew { + value: Some(3), + source: MaxNewSource::Flag, + }; + let mut stage = Stage::new(capped, None, Path::new("/repo")); + stage.incomplete = true; + assert!(!stage.may_admit_new()); + stage.plan(&classify(&offers, &RecordedIndex::default(), ""), |_| true); + let codes: Vec<&str> = stage.warnings().iter().map(|(c, _)| *c).collect(); + assert_eq!(codes, [ROLLOUT_INCOMPLETE_LOOKUP]); + let mut unlimited = Stage::new(MaxNew::UNLIMITED, None, Path::new("/repo")); + unlimited.incomplete = true; + assert!(unlimited.may_admit_new()); + unlimited.plan(&classify(&offers, &RecordedIndex::default(), ""), |_| true); + assert!(unlimited.warnings().is_empty()); + } + + /// A lockless NuGet / Cargo pin (the hosted rewriter's own + /// output when the project has no lockfile) is never a discovery ref, + /// so `HostedPin::all` drops it. Re-scanned under `--max-new-patches 1` + /// it must read as ALREADY through the recorded view (as `scan` builds + /// it), not as NEW: otherwise every re-scan spends the one slot on the + /// pin it already wrote and the genuinely new patch is deferred forever. + #[tokio::test] + async fn a_rescanned_lockless_pin_is_already_not_new_under_a_cap() { + const NUGET: &str = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa"; + const CARGO: &str = "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb"; + const GRANT: &str = "cccccccc-3333-4333-8333-cccccccccccc"; + let tmp = tempfile::tempdir().unwrap(); + let nuget_key = format!("socket-patch-{NUGET}"); + std::fs::write( + tmp.path().join("nuget.config"), + format!( + "\n\n \n \ + \n \ + \n \n \n \ + \n \n \ + \n\n" + ), + ) + .unwrap(); + std::fs::write( + tmp.path().join("Cargo.toml"), + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde = {{ version = \"1\", registry = \"socket-patch-{CARGO}\" }}\n" + ), + ) + .unwrap(); + std::fs::create_dir(tmp.path().join(".cargo")).unwrap(); + std::fs::write( + tmp.path().join(".cargo/config.toml"), + format!( + "[registries.socket-patch-{CARGO}]\nindex = \ + \"sparse+https://patch.socket.dev/patch-registry/cargo/{GRANT}/{CARGO}/index/\"\n" + ), + ) + .unwrap(); + let discovery = crate::vex::discover::discover_patched_refs(tmp.path()).await; + assert_eq!(discovery.unlocked_pins.len(), 2, "{discovery:#?}"); + let pins: Vec<(String, String)> = HostedPin::all(&discovery) + .into_iter() + .map(|p| (p.purl, p.uuid)) + .collect(); + assert!(pins.is_empty(), "lockless pins are never refs: {pins:?}"); + + let results = vec![ + offer("pkg:nuget/Newtonsoft.Json@13.0.3", NUGET, "", &["critical"]), + offer("pkg:cargo/serde@1.0.200", CARGO, "", &["critical"]), + offer("pkg:npm/fresh@1.0.0", "fresh", "", &["low"]), + ]; + let offers = offers_from_results(&results, true); + let index = RecordedIndex::new(None, &pins).with_unlocked_pins(discovery.unlocked_pins); + let one = MaxNew { + value: Some(1), + source: MaxNewSource::Flag, + }; + let mut stage = Stage::new(one, None, Path::new("/repo")); + stage.plan(&classify(&offers, &index, ""), |_| true); + let counts = &stage.json()["counts"]; + assert_eq!(counts["already"], 2, "{counts}"); + assert_eq!(counts["new"], 1, "{counts}"); + assert_eq!(counts["deferred"], 0, "{counts}"); + + // A version outside the cargo pin's requirement is not routed by it. + assert!(index.uuids("pkg:cargo/serde@2.0.0").is_empty()); + assert!(!index.records_package("pkg:cargo/serde@2.0.0")); + } +} From d70d9c60247a910c3f35c399a162fd3fea12e5bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:14:36 +0000 Subject: [PATCH 4/5] Test Stage::may_admit on a spent budget Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/rollout/stage.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/socket-patch-core/src/rollout/stage.rs b/crates/socket-patch-core/src/rollout/stage.rs index 5d4c423da..0a27ad5b5 100644 --- a/crates/socket-patch-core/src/rollout/stage.rs +++ b/crates/socket-patch-core/src/rollout/stage.rs @@ -814,6 +814,25 @@ mod tests { )); } + #[test] + fn a_spent_budget_admits_only_packages_an_earlier_directory_admitted() { + let zero = MaxNew { + value: Some(0), + source: MaxNewSource::Flag, + }; + let mut stage = Stage::new(zero, None, Path::new("/repo")); + stage + .already_admitted + .insert(PurlKey::new("pkg:npm/a@1").into_string()); + assert!(stage.may_admit("pkg:npm/a@1")); + assert!(!stage.may_admit("pkg:npm/b@1")); + stage.incomplete = true; + assert!( + !stage.may_admit("pkg:npm/a@1"), + "a failed lookup admits nothing" + ); + } + #[test] fn incomplete_lookups_warn_only_when_they_deferred_something() { let results = vec![offer("pkg:npm/a@1", "ua", "", &["critical"])]; From 010e80bc2318a066b3df1708e1f65bedf4705b32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:29:17 +0000 Subject: [PATCH 5/5] Address review: keep is_gone, simplify may_admit Keep is_gone and its status table: build_failed keeps a withdrawn-sweep pin, which the vendor fetch reads the other way, so the list is not obvious. Stage::may_admit states the incomplete-lookup guard once, and the redirect's may_write builds each candidate's purl once. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 4 +-- .../src/commands/scan/withdrawn.rs | 26 +++++++++++++++++-- crates/socket-patch-core/src/rollout/stage.rs | 6 ++--- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 27fe4a43e..86811a2c4 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1058,8 +1058,8 @@ async fn run_redirect_selected_untracked( let may_write = rollout.as_ref().is_none_or(|gate| { let new = gate.new_keys(); candidates.iter().any(|c| { - !new.contains(&(sel_purl(c), c.dep.patch_uuid.clone())) - || gate.stage.may_admit(&sel_purl(c)) + let purl = sel_purl(c); + gate.stage.may_admit(&purl) || !new.contains(&(purl, c.dep.patch_uuid.clone())) }) }); let mut lock: Option = if !common.dry_run && !candidates.is_empty() && may_write { diff --git a/crates/socket-patch-cli/src/commands/scan/withdrawn.rs b/crates/socket-patch-cli/src/commands/scan/withdrawn.rs index c1302f1b8..00f3c8036 100644 --- a/crates/socket-patch-cli/src/commands/scan/withdrawn.rs +++ b/crates/socket-patch-cli/src/commands/scan/withdrawn.rs @@ -52,6 +52,13 @@ pub(super) const REDIRECT_WITHDRAWN_RESTORE_FAILED: &str = "redirect_withdrawn_r /// could not tell whether any pin was withdrawn. Every pin is kept. pub(super) const REDIRECT_WITHDRAWN_CHECK_FAILED: &str = "redirect_withdrawn_check_failed"; +/// Whether a reference `status` means the pinned patch is gone for good. +/// `build_failed` keeps the pin (the vendor fetch reads it as unavailable): +/// a failed build can be rebuilt. +fn is_gone(status: &str) -> bool { + matches!(status, "withdrawn" | "not_found") +} + /// What the sweep did, for the `--json` envelope. Human output is printed /// as the sweep runs. #[derive(Debug, Default)] @@ -178,8 +185,7 @@ pub(super) async fn sweep( .iter() .filter_map(|pin| { let status = &references.get(&pin.uuid)?.status; - matches!(status.as_str(), "withdrawn" | "not_found") - .then(|| (pin.clone(), status.clone())) + is_gone(status).then(|| (pin.clone(), status.clone())) }) .collect(); out.references = Some(KnownReferences::settled(&uuids, references)); @@ -282,6 +288,22 @@ mod tests { } } + #[test] + fn only_withdrawn_and_not_found_are_gone() { + assert!(is_gone("withdrawn")); + assert!(is_gone("not_found")); + for kept in [ + "granted", + "reused", + "pending_build", + "build_failed", + "forbidden", + "", + ] { + assert!(!is_gone(kept), "{kept} must keep the pin"); + } + } + #[test] fn scope_matches_by_release_identity() { let pins = vec![ diff --git a/crates/socket-patch-core/src/rollout/stage.rs b/crates/socket-patch-core/src/rollout/stage.rs index 0a27ad5b5..d23cabf8a 100644 --- a/crates/socket-patch-core/src/rollout/stage.rs +++ b/crates/socket-patch-core/src/rollout/stage.rs @@ -364,9 +364,9 @@ impl Stage { /// Whether a NEW row of `purl` could be admitted: budget left, or its /// package already admitted by an earlier directory. pub fn may_admit(&self, purl: &str) -> bool { - self.may_admit_new() - || (!(self.capped() && self.incomplete) - && self + !(self.capped() && self.incomplete) + && (self.budget.value != Some(0) + || self .already_admitted .contains(&PurlKey::new(purl).into_string())) }