Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c0e4ec0
Route remove and rollback through one core target grammar
mikolalysenko Oct 7, 2026
93d61af
Make get select exact packages within --ecosystems on every path
mikolalysenko Oct 7, 2026
e076d29
Rewrite the bare-UUID shortcut on the first UUID operand
mikolalysenko Oct 7, 2026
37753c2
Document the shared package target grammar
mikolalysenko Oct 7, 2026
645d005
Refuse package names that select several packages
mikolalysenko Oct 7, 2026
367d802
Drop formatting-only churn and stale names from tests
mikolalysenko Oct 7, 2026
715ff5b
Merge remote-tracking branch 'origin/main' into arch-fix/target-grammar
mikolalysenko Oct 7, 2026
ebe265b
Merge remote-tracking branch 'origin/main' into arch-fix/target-grammar
mikolalysenko Oct 7, 2026
647f30f
Merge remote-tracking branch 'origin/main' into prfix2/1034
mikolalysenko Oct 7, 2026
99033bd
Fix two Bugbot findings in the target grammar
mikolalysenko Oct 7, 2026
836adb1
Merge origin/main into arch-fix/target-grammar
mikolalysenko Oct 8, 2026
a4e3afe
Merge origin/main into arch-fix/target-grammar
claude Oct 8, 2026
fd6a5f4
Merge remote-tracking branch 'origin/main' into arch-fix/target-grammar
claude Oct 8, 2026
a06618f
Merge remote-tracking branch 'origin/main' into arch-fix/target-grammar
claude Oct 9, 2026
8e62ca9
Let a full-name match settle an ambiguous package name
claude Oct 9, 2026
703694e
Keep the golang ambiguity test on last-segment-only matches
claude Oct 9, 2026
ca475de
Act only on the package a name's ambiguity check settles on
claude Oct 9, 2026
12af0a2
Merge remote-tracking branch 'origin/main' into arch-fix/target-grammar
claude Oct 9, 2026
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ socket-patch vendor # eject an existing hosted patch set
socket-patch rollback # restore upstream dependencies
```

`get` also accepts a GHSA, patch UUID, PURL, or package name. `scan` selects from
`get` also accepts a GHSA, patch UUID, PURL, or exact package name (every installed
version of that name is searched; near names are only suggested). `scan` selects from
patches your account can download, preferring the highest severity, then the most
advisories fixed, then the newest publication date. Existing patches are upgraded
only by a better-ranked patch.
Expand Down
17 changes: 10 additions & 7 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use socket_patch_core::vendor::{VendorServiceConfig, VendorSource};
/// loudly instead of silently matching nothing.
///
/// Without this, an unsupported name parsed fine and was then silently
/// dropped by `partition_purls`/`crawl_all_ecosystems`, so the user got a
/// dropped by `partition_purls`/`crawl_ecosystems`, so the user got a
/// "0 patches" result with no hint that the ecosystem name was the cause.
fn parse_supported_ecosystem(s: &str) -> Result<String, String> {
if Ecosystem::all().iter().any(|e| e.cli_name() == s) {
Expand Down
478 changes: 295 additions & 183 deletions crates/socket-patch-cli/src/commands/get.rs

Large diffs are not rendered by default.

175 changes: 138 additions & 37 deletions crates/socket-patch-cli/src/commands/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
use socket_patch_core::manifest::schema::PatchManifest;
use socket_patch_core::patch::redirect::upstream::HostedPin;
use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed, TelemetryAuth};
use socket_patch_core::utils::purl::patch_matches;
use socket_patch_core::utils::purl_key::PurlKey;
use socket_patch_core::utils::target::Target;
use socket_patch_core::vendor::{
load_state, RevertOpts, VendorEntry, VendorState, VENDOR_STATE_REL,
};
Expand All @@ -23,38 +23,38 @@ use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchE
use crate::ui::short_uuid;
use crate::ui::{plural, sweep_failure};

/// Vendor-ledger entries matching a remove identifier
/// Vendor-ledger entries matching a remove target
/// ([`socket_patch_core::ledgers::Ledgers::matching`]), sorted by key for
/// deterministic event order. With the manifest, an entry the matched
/// manifest keys claim matches whatever patch generation it recorded
/// (#999); the ledger-only path passes `None`.
fn vendor_entries_matching(
state: &VendorState,
manifest: Option<&PatchManifest>,
identifier: &str,
target: &Target,
) -> Vec<(String, VendorEntry)> {
socket_patch_core::ledgers::Ledgers {
manifest,
vendor: Some(state),
..Default::default()
}
.matching(identifier)
.matching(target)
.vendor
}

/// Drop every manifest entry matching `identifier` except `exclusions`
/// Drop every manifest entry matching `target` except `exclusions`
/// (drift-kept vendored purls, whose record must survive with their
/// vendored state). Returns the removed purls, sorted.
fn remove_matching(
manifest: &mut PatchManifest,
identifier: &str,
target: &Target,
exclusions: &HashSet<String>,
) -> Vec<String> {
let mut removed: Vec<String> = manifest
.patches
.iter()
.filter(|(purl, patch)| {
patch_matches(purl, &patch.uuid, identifier) && !exclusions.contains(*purl)
target.matches_patch(purl, &patch.uuid) && !exclusions.contains(*purl)
})
.map(|(purl, _)| purl.clone())
.collect();
Expand Down Expand Up @@ -274,7 +274,8 @@ fn format_blob_sweep(

#[derive(Args)]
pub struct RemoveArgs {
/// Package PURL or patch UUID.
/// Patch UUID, package PURL (`pkg:npm/lodash@4.17.21`, or versionless
/// for every version), or exact package name (`lodash`)
pub identifier: String,

#[command(flatten)]
Expand Down Expand Up @@ -316,6 +317,9 @@ const DRY_RUN_FOOTER: &str = "Dry run: no changes made.";

pub async fn run(args: RemoveArgs) -> i32 {
apply_env_toggles(&args.common);
// The shared target grammar: a UUID, a purl (versioned or not) or an
// exact package name. Every store below matches through it.
let target = Target::parse(&args.identifier);

// Self-enforced usage error (exit 2, like scan's mode conflicts):
// `--skip-rollback` keeps the tree and drops the state,
Expand Down Expand Up @@ -441,16 +445,6 @@ pub async fn run(args: RemoveArgs) -> i32 {
}
};

// Find matching patches to show what will be removed (sorted: the
// manifest is a HashMap, and the listing must be deterministic).
let mut matching: Vec<_> = manifest
.patches
.iter()
.filter(|(purl, patch)| patch_matches(purl, &patch.uuid, &args.identifier))
.collect();
matching.sort_by(|a, b| a.0.cmp(b.0));
let matched_keys: Vec<String> = matching.iter().map(|(purl, _)| (*purl).clone()).collect();

// The vendor ledger, loaded ONCE under the lock: it scopes the nested
// rollback (vendor-owned purls are not restored in place) and drives
// the vendored leg. An unreadable ledger degrades to "nothing vendored"
Expand All @@ -462,13 +456,58 @@ pub async fn run(args: RemoveArgs) -> i32 {
Ok(VendorState::default())
};

// A name reaching several packages by last segment (`core` →
// `@angular/core` and `@babel/core`) is refused across every store:
// `remove` acts on one package per name, and only on the one the
// check settled on (`lodash` beside `@types/lodash` is `lodash` alone).
let target = {
let ledger_purls: Vec<&str> = vendor_state_result
.as_ref()
.map(|state| {
state
.entries
.iter()
.map(|(key, entry)| entry.ambiguity_purl(key, &target))
.collect()
})
.unwrap_or_default();
let candidates = manifest
.patches
.keys()
.map(String::as_str)
.chain(ledger_purls)
.chain(hosted_pins.iter().map(|pin| pin.purl.as_str()));
match target.settle(candidates) {
Ok(settled) => settled,
Err(msg) => {
emit_error_envelope(
args.common.json,
args.common.dry_run,
"ambiguous_target",
msg,
);
return 1;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
};

// Find matching patches to show what will be removed (sorted: the
// manifest is a HashMap, and the listing must be deterministic).
let mut matching: Vec<_> = manifest
.patches
.iter()
.filter(|(purl, patch)| target.matches_patch(purl, &patch.uuid))
.collect();
matching.sort_by(|a, b| a.0.cmp(b.0));
let matched_keys: Vec<String> = matching.iter().map(|(purl, _)| (*purl).clone()).collect();

if matching.is_empty() {
// Ledger-only entries (vendored mode keeps no manifest record) —
// `remove` is their per-purl exit path (alongside `vendor
// --revert`'s all-at-once). An unreadable ledger falls through to
// `not_found`: nothing is mutated on that path.
if let Ok(state) = vendor_state_result {
let ledger_matches = vendor_entries_matching(&state, None, &args.identifier);
let ledger_matches = vendor_entries_matching(&state, None, &target);
if !ledger_matches.is_empty() {
return remove_ledger_only(&args, ledger_matches, state, &telemetry).await;
}
Expand All @@ -477,7 +516,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
// Hosted-only patches likewise have no manifest entry — their
// lockfile pins are their only persistence, and `remove` is their
// per-purl exit path (restoring the upstream entry IS the removal).
let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier, &[]);
let hosted_matches = hosted_pins_matching(&hosted_pins, &target, &[]);
if !hosted_matches.is_empty() {
return remove_hosted_only(&args, hosted_matches, &telemetry).await;
}
Expand All @@ -497,9 +536,10 @@ pub async fn run(args: RemoveArgs) -> i32 {
// blast radius explicit so the user understands why a single
// `remove pkg:pypi/foo@1.0` is removing several variants.
if loud {
let variants = args.identifier.starts_with("pkg:")
&& !args.identifier.contains('?')
&& matching.len() > 1;
// Only an exact base purl expands to release variants; a name or
// versionless purl matching several entries is "several patches".
let variants =
target.is_versioned_purl() && !args.identifier.contains('?') && matching.len() > 1;
eprintln!(
"{}",
format_remove_header(
Expand Down Expand Up @@ -533,9 +573,9 @@ pub async fn run(args: RemoveArgs) -> i32 {
} else {
let vendored = vendor_state_result
.as_ref()
.map(|st| vendor_entries_matching(st, Some(&manifest), &args.identifier).len())
.map(|st| vendor_entries_matching(st, Some(&manifest), &target).len())
.unwrap_or(0);
let hosted = hosted_pins_matching(&hosted_pins, &args.identifier, &matched_keys).len();
let hosted = hosted_pins_matching(&hosted_pins, &target, &matched_keys).len();
(vendored, hosted)
};
let prompt = remove_prompt(
Expand Down Expand Up @@ -594,7 +634,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
&socket_dir,
&manifest,
&vendored_keys,
InnerSelection::Identifier(Some(&args.identifier)),
InnerSelection::Identifier(Some(&target)),
&super::rollback::superseded_by_hosted(&manifest, &hosted_pins),
Some(&telemetry_client),
)
Expand Down Expand Up @@ -711,8 +751,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
return 1;
}
};
let vendored_matches =
vendor_entries_matching(&vendor_state, Some(&manifest), &args.identifier);
let vendored_matches = vendor_entries_matching(&vendor_state, Some(&manifest), &target);
let mut vendor_leg = RemoveVendorLeg::default();
if !vendored_matches.is_empty() {
if args.skip_rollback {
Expand Down Expand Up @@ -755,7 +794,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
// carried into the success envelope's `warnings[]`.
let mut hosted_leg_warnings: Vec<(String, String)> = Vec::new();
if !args.skip_rollback {
let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier, &matched_keys);
let hosted_matches = hosted_pins_matching(&hosted_pins, &target, &matched_keys);
if !hosted_matches.is_empty() {
let leg = match unwind_hosted(&args.common, &hosted_matches).await {
Ok(leg) => {
Expand Down Expand Up @@ -817,7 +856,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
let removed = if args.preserve_state {
Vec::new()
} else {
remove_matching(&mut updated_manifest, &args.identifier, &excluded_kept)
remove_matching(&mut updated_manifest, &target, &excluded_kept)
};
if removed.is_empty() && !args.preserve_state {
// Every matching entry was drift-kept (the identifier matched, so
Expand Down Expand Up @@ -1618,7 +1657,11 @@ mod tests {
fn remove_base_purl_removes_all_variants() {
let mut manifest = multi_variant_manifest();

let removed = remove_matching(&mut manifest, "pkg:pypi/six@1.16.0", &Default::default());
let removed = remove_matching(
&mut manifest,
&Target::parse("pkg:pypi/six@1.16.0"),
&Default::default(),
);

// All three release variants removed (sorted); the npm package untouched.
assert_eq!(removed.len(), 3);
Expand All @@ -1637,7 +1680,7 @@ mod tests {

let removed = remove_matching(
&mut manifest,
"pkg:pypi/six@1.16.0?artifact_id=sdist",
&Target::parse("pkg:pypi/six@1.16.0?artifact_id=sdist"),
&Default::default(),
);

Expand All @@ -1653,7 +1696,11 @@ mod tests {
fn remove_by_uuid_removes_single_variant() {
let mut manifest = multi_variant_manifest();

let removed = remove_matching(&mut manifest, "uuid-cp312", &Default::default());
let removed = remove_matching(
&mut manifest,
&Target::parse("uuid-cp312"),
&Default::default(),
);

assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=wheel-cp312"]);
assert_eq!(manifest.patches.len(), 3);
Expand All @@ -1673,7 +1720,11 @@ mod tests {
setup: None,
};

let removed = remove_matching(&mut manifest, "pkg:npm/foo@1.0", &Default::default());
let removed = remove_matching(
&mut manifest,
&Target::parse("pkg:npm/foo@1.0"),
&Default::default(),
);

assert_eq!(removed, vec!["pkg:npm/foo@1.0"]);
assert_eq!(manifest.patches.len(), 1);
Expand All @@ -1690,12 +1741,54 @@ mod tests {
let mut manifest = multi_variant_manifest();
let before = manifest.clone();

let removed = remove_matching(&mut manifest, "pkg:npm/not-here@9.9.9", &Default::default());
let removed = remove_matching(
&mut manifest,
&Target::parse("pkg:npm/not-here@9.9.9"),
&Default::default(),
);

assert!(removed.is_empty(), "nothing should match");
assert_eq!(manifest, before, "manifest left intact");
}

/// The shared target grammar: a bare name and a versionless purl
/// select every recorded version (`remove six` used to be "No patch
/// found"), and a name stays exact (`six` is not `sixer`).
#[test]
fn remove_by_name_or_versionless_purl_removes_every_version() {
for token in ["six", "pkg:pypi/six"] {
let mut manifest = multi_variant_manifest();
manifest
.patches
.insert("pkg:pypi/six@1.17.0".to_string(), make_record("uuid-17"));
manifest.patches.insert(
"pkg:pypi/sixer@1.0.0".to_string(),
make_record("uuid-sixer"),
);
let removed =
remove_matching(&mut manifest, &Target::parse(token), &Default::default());
assert!(
removed.contains(&"pkg:pypi/six@1.17.0".to_string()),
"{token}: {removed:?}"
);
assert!(
removed.iter().all(|p| p.starts_with("pkg:pypi/six@")),
"{token}: {removed:?}"
);
assert!(
manifest.patches.contains_key("pkg:pypi/sixer@1.0.0"),
"{token}"
);
assert!(
!manifest
.patches
.keys()
.any(|k| k.starts_with("pkg:pypi/six@")),
"{token}"
);
}
}

/// A base PURL must not bleed across versions: removing `six@1.16.0`
/// leaves `six@1.17.0` (and its variants) in place.
#[test]
Expand All @@ -1714,7 +1807,11 @@ mod tests {
setup: None,
};

let removed = remove_matching(&mut manifest, "pkg:pypi/six@1.16.0", &Default::default());
let removed = remove_matching(
&mut manifest,
&Target::parse("pkg:pypi/six@1.16.0"),
&Default::default(),
);

assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=sdist"]);
assert_eq!(manifest.patches.len(), 1);
Expand All @@ -1731,7 +1828,11 @@ mod tests {
let exclusions: HashSet<String> =
["pkg:pypi/six@1.16.0?artifact_id=sdist".to_string()].into();

let removed = remove_matching(&mut manifest, "pkg:pypi/six@1.16.0", &exclusions);
let removed = remove_matching(
&mut manifest,
&Target::parse("pkg:pypi/six@1.16.0"),
&exclusions,
);

assert_eq!(
removed.len(),
Expand Down
Loading
Loading