Repository navigation
Use one package target grammar for get, remove, rollback and the UUID shortcut - #1034
Mikola Lysenko (mikolalysenko) wants to merge 11 commits into
Conversation
Add socket_patch_core::utils::target: Target::parse classifies a token as a UUID, CVE, GHSA, purl or exact package name, and Target::matches_patch / matches_package match records and installed packages through it (names via policy::package_spec_matches, the matcher scan --package and socket.yml already use). Delete utils::purl::patch_matches and the api client's private is_valid_uuid copy; Ledgers::matching, VendorEntry::matches_target (renamed from matches_identifier), remove and rollback now take a parsed Target. remove and rollback accept a bare name and a versionless purl (every recorded version) instead of reporting 'No patch found', and rollback treats an npm @scope/name as a name rather than a path glob. Part of architecture audit theme 3.B (package target grammar). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
get now classifies its identifier with the core Target parser and drops its own IdentifierType, CVE/GHSA regexes and purl_has_version copies. - B11: get <name> no longer fuzzy-picks one installed purl. It searches every installed version of the EXACT name (get lodash also searches a nested lodash@4.17.4; get yaml no longer patches yaml-ast-parser). With no exact match it reports no_match and only suggests near names. - B56: --ecosystems scopes the package-name crawl and filters every search result, so get CVE-X -e npm no longer records or rewrites the advisory's PyPI patch. - B29 (#453) / B57: get <uuid> applies the same rules as the search path: a patch outside --ecosystems is not acted on, and a socket.yml bypass emits policy_bypassed in agent, hosted and vendored modes. Delete the crawl_all_ecosystems wrapper (crawl_ecosystems(opts, None)). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
socket-patch --json <UUID> failed with "unexpected argument '--json'" because the shortcut only looked at argv[1]. It now fires on the first UUID-shaped token before any subcommand name, using the core target grammar's is_uuid_shaped; the CLI's looks_like_uuid copy is deleted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A bare name matches its full name or its last segment, so one short token could select several packages: `remove core` removed the patches for both @angular/core and @babel/core, and `get v2` reached every Go v2+ module. get, remove and rollback act on one package per name, so they now refuse a name whose matches cover more than one package identity (exit 1, naming each as a versionless purl; remove's JSON code is ambiguous_target). A Go major-version suffix is never a name. scan --package and socket.yml keep their semantics. Also: - rollback tries a slash-containing token (composer vendor/pkg, a go module path) as a target before treating it as a path glob, so it takes the same names as get and remove. - get <uuid> checks --ecosystems before the paid gate, the "Found patch" line and the patch_fetched event. - get <name> runs its per-version searches concurrently through ordered_concurrent; a failed search still fails the run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Restore e2e_socket_yml_policy.rs to main's layout and keep only the new get-by-uuid policy test, and update test comments that still named the deleted crawl_all_ecosystems and IdentifierType::Package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # crates/socket-patch-cli/src/commands/remove.rs # crates/socket-patch-cli/src/commands/rollback.rs
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Ambiguity misses encoded vendor keys
- Included both ledger keys and base_purl values in ambiguity checks for remove and rollback commands to catch case-encoded golang keys that were previously missed.
Or push these changes by commenting:
@cursor push 7917c6c761
Preview (7917c6c761)
diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs
--- a/crates/socket-patch-cli/src/commands/list.rs
+++ b/crates/socket-patch-cli/src/commands/list.rs
@@ -431,7 +431,10 @@
detail: detail.clone(),
});
} else if !args.common.silent {
- eprintln!("Warning: {}", crate::commands::rollback::capitalize_first(detail));
+ eprintln!(
+ "Warning: {}",
+ crate::commands::rollback::capitalize_first(detail)
+ );
}
}
let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent);
@@ -773,12 +776,18 @@
let listings = HostedListing::from_pins(
&[
pin("pkg:npm/minimist@1.2.2", &record.uuid),
- pin("pkg:npm/other@1.0.0", "33333333-3333-4333-8333-333333333333"),
+ pin(
+ "pkg:npm/other@1.0.0",
+ "33333333-3333-4333-8333-333333333333",
+ ),
],
Some(&legacy),
);
assert_eq!(listings[0].record, record);
- assert_eq!(listings[1].record.uuid, "33333333-3333-4333-8333-333333333333");
+ assert_eq!(
+ listings[1].record.uuid,
+ "33333333-3333-4333-8333-333333333333"
+ );
assert!(listings[1].record.vulnerabilities.is_empty());
assert_eq!(listings[1].lockfiles, vec!["yarn.lock".to_string()]);
}
diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs
--- a/crates/socket-patch-cli/src/commands/mod.rs
+++ b/crates/socket-patch-cli/src/commands/mod.rs
@@ -1,7 +1,7 @@
pub mod apply;
pub(crate) mod bun_preflight;
+pub(crate) mod composer_hints;
pub(crate) mod context;
-pub(crate) mod composer_hints;
pub(crate) mod fetch_stage;
pub mod get;
pub mod hosted_bundle;
@@ -9,11 +9,11 @@
pub(crate) mod lock_cli;
pub mod remove;
pub mod repair;
-pub(crate) mod vendored_backend;
pub mod rollback;
pub mod scan;
pub mod update;
pub mod vendor;
+pub(crate) mod vendored_backend;
pub mod vex;
pub(crate) mod vex_consumed;
pub(crate) mod vex_sources;
@@ -141,9 +141,11 @@
common: &crate::args::GlobalArgs,
root: &Path,
) -> socket_patch_core::patch::redirect::RedirectState {
- hosted_state_from_pins(&socket_patch_core::patch::redirect::upstream::HostedPin::all(
- &discover_wiring(common, root).await,
- ))
+ hosted_state_from_pins(
+ &socket_patch_core::patch::redirect::upstream::HostedPin::all(
+ &discover_wiring(common, root).await,
+ ),
+ )
}
/// [`hosted_state_from_lockfiles`] over already-discovered pins. A purl
@@ -153,10 +155,8 @@
) -> socket_patch_core::patch::redirect::RedirectState {
let mut state = socket_patch_core::patch::redirect::RedirectState::new();
for pin in pins {
- state
- .records
- .entry(pin.purl.clone())
- .or_insert_with(|| socket_patch_core::manifest::schema::PatchRecord {
+ state.records.entry(pin.purl.clone()).or_insert_with(|| {
+ socket_patch_core::manifest::schema::PatchRecord {
uuid: pin.uuid.clone(),
exported_at: String::new(),
files: Default::default(),
@@ -164,7 +164,8 @@
description: String::new(),
license: String::new(),
tier: String::new(),
- });
+ }
+ });
}
state
}
@@ -191,4 +192,3 @@
}
}
}
-
diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs
--- a/crates/socket-patch-cli/src/commands/remove.rs
+++ b/crates/socket-patch-cli/src/commands/remove.rs
@@ -464,15 +464,21 @@
// `@angular/core` and `@babel/core`) is refused across every store:
// `remove` acts on one package per name.
{
- let ledger_purls: Vec<&str> = vendor_state_result
+ let ledger_candidates: Vec<&str> = vendor_state_result
.as_ref()
- .map(|state| state.entries.keys().map(String::as_str).collect())
+ .map(|state| {
+ state
+ .entries
+ .iter()
+ .flat_map(|(k, e)| [k.as_str(), e.base_purl.as_str()])
+ .collect()
+ })
.unwrap_or_default();
let candidates = manifest
.patches
.keys()
.map(String::as_str)
- .chain(ledger_purls)
+ .chain(ledger_candidates)
.chain(hosted_pins.iter().map(|pin| pin.purl.as_str()));
if let Some(msg) = target.ambiguity(candidates) {
emit_error_envelope(
diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs
--- a/crates/socket-patch-cli/src/commands/rollback.rs
+++ b/crates/socket-patch-cli/src/commands/rollback.rs
@@ -1252,13 +1252,15 @@
let mut identifiers = identifiers;
let mut globs: Vec<String> = Vec::new();
for raw in path_scope.raw() {
- let named = is_name_shaped_path(raw).then(|| Target::parse(raw)).filter(|t| {
- t.kind() == TargetKind::Name
- && (!ledgers.matching(t).is_empty()
- || redirect_records
- .iter()
- .any(|(purl, uuid)| t.matches_patch(purl, uuid)))
- });
+ let named = is_name_shaped_path(raw)
+ .then(|| Target::parse(raw))
+ .filter(|t| {
+ t.kind() == TargetKind::Name
+ && (!ledgers.matching(t).is_empty()
+ || redirect_records
+ .iter()
+ .any(|(purl, uuid)| t.matches_patch(purl, uuid)))
+ });
match named {
Some(t) => identifiers.push(t),
None => globs.push(raw.clone()),
@@ -1301,7 +1303,12 @@
.manifest
.iter()
.map(String::as_str)
- .chain(found.vendor.iter().map(|(k, _)| k.as_str()))
+ .chain(
+ found
+ .vendor
+ .iter()
+ .flat_map(|(k, e)| [k.as_str(), e.base_purl.as_str()]),
+ )
.chain(hosted_found),
);
manifest_scope.extend(found.manifest);
diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs
--- a/crates/socket-patch-cli/src/commands/scan/discovery.rs
+++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs
@@ -168,29 +168,32 @@
}
// `(ledger key, base purl, entry)`; the artifact fallback has no
// entries to probe, so it never reports unwired keys.
- let candidates: Vec<(String, String, Option<&socket_patch_core::vendor::VendorEntry>)> =
- match state {
- Ok(state) => state
- .entries
- .iter()
- .map(|(key, entry)| {
- (
- key.clone(),
- strip_purl_qualifiers(&entry.base_purl).to_string(),
- Some(entry),
- )
- })
- .collect(),
- // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
- // recover the vendored set from the committed artifacts, or
- // `scan --prune` (whose ledger exemption also degrades to empty)
- // would delete still-vendored packages' manifest entries and blobs.
- Err(_) => vendored_purls_from_artifacts(common)
- .await
- .into_iter()
- .map(|base| (base.clone(), base, None))
- .collect(),
- };
+ let candidates: Vec<(
+ String,
+ String,
+ Option<&socket_patch_core::vendor::VendorEntry>,
+ )> = match state {
+ Ok(state) => state
+ .entries
+ .iter()
+ .map(|(key, entry)| {
+ (
+ key.clone(),
+ strip_purl_qualifiers(&entry.base_purl).to_string(),
+ Some(entry),
+ )
+ })
+ .collect(),
+ // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
+ // recover the vendored set from the committed artifacts, or
+ // `scan --prune` (whose ledger exemption also degrades to empty)
+ // would delete still-vendored packages' manifest entries and blobs.
+ Err(_) => vendored_purls_from_artifacts(common)
+ .await
+ .into_iter()
+ .map(|base| (base.clone(), base, None))
+ .collect(),
+ };
// Composer by release identity: a ledger `@3.0.2.0` is the crawled
// `@3.0.2`, not a second package to supplement.
let key = |p: &str| composer_purl_identity(p).unwrap_or_else(|| normalize_purl(p).into_owned());
@@ -1045,7 +1048,9 @@
..GlobalArgs::default()
};
let state = socket_patch_core::vendor::load_state(root).await;
- vendored_ledger_supplement(&args, crawled, &state).await.packages
+ vendored_ledger_supplement(&args, crawled, &state)
+ .await
+ .packages
}
/// A ledger entry vendored as `@3.0.2.0` is the crawled composer
@@ -1080,7 +1085,9 @@
out.iter().map(|p| &p.purl).collect::<Vec<_>>()
);
- let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await.packages;
+ let out = vendored_ledger_supplement(&args, &[], &Ok(state))
+ .await
+ .packages;
assert_eq!(
out.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
vec!["pkg:composer/psr/log@3.0.2.0"]
@@ -1183,7 +1190,10 @@
let state = npm_ledger_with_lock(tmp.path(), lock.as_deref()).await;
let out = vendored_ledger_supplement(&args, &[], &state).await;
assert_eq!(
- out.packages.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
+ out.packages
+ .iter()
+ .map(|p| p.purl.as_str())
+ .collect::<Vec<_>>(),
vec!["pkg:npm/left-pad@1.3.0"],
"lock={lock:?}"
);
diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs
--- a/crates/socket-patch-cli/src/commands/scan/hosted.rs
+++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs
@@ -988,7 +988,8 @@
socket_patch_core::utils::fs::read_regular_to_string_sync(path).ok()
})
};
- let rewrite_options = || RewriteOptions {
+ let rewrite_options = || {
+ RewriteOptions {
dry_run: common.dry_run,
targets_pipenv_lock,
pipenv_major,
@@ -1000,6 +1001,7 @@
npm_allow_remote_config: !common.no_npm_allow_remote_config,
npm_outer: &npm_outer,
blocking: true,
+ }
};
// The rollout gate plans again without its deferred rows: keep what
// the second pass needs.
@@ -4744,19 +4746,43 @@
use super::npm_allow_remote_one_line;
let hosts = ["patch.socket.dev"];
let cases = [
- (npm_allow_remote_configured_detail(&hosts, true, false), "Note: set"),
- (npm_allow_remote_configured_detail(&hosts, false, false), "Note: set"),
- (npm_allow_remote_configured_detail(&hosts, true, true), "Note: would set"),
- (npm_allow_remote_already_detail(&hosts), "Note: .npmrc already"),
- (npm_allow_remote_user_set_detail(&hosts, "none"), "Warning: npm >=12"),
- (npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), "Warning: npm >=12"),
+ (
+ npm_allow_remote_configured_detail(&hosts, true, false),
+ "Note: set",
+ ),
+ (
+ npm_allow_remote_configured_detail(&hosts, false, false),
+ "Note: set",
+ ),
+ (
+ npm_allow_remote_configured_detail(&hosts, true, true),
+ "Note: would set",
+ ),
+ (
+ npm_allow_remote_already_detail(&hosts),
+ "Note: .npmrc already",
+ ),
+ (
+ npm_allow_remote_user_set_detail(&hosts, "none"),
+ "Warning: npm >=12",
+ ),
+ (
+ npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"),
+ "Warning: npm >=12",
+ ),
(npm_allow_remote_manual_detail(&hosts), "Warning: npm >=12"),
- (npm_allow_remote_unreadable_detail(&hosts, "is a symlink"), "Warning: npm >=12"),
+ (
+ npm_allow_remote_unreadable_detail(&hosts, "is a symlink"),
+ "Warning: npm >=12",
+ ),
];
for (detail, start) in cases {
let line = npm_allow_remote_one_line(&detail);
assert!(line.starts_with(start), "{line}");
- assert!(!line.contains('\n') && line.ends_with("(details: --verbose)."), "{line}");
+ assert!(
+ !line.contains('\n') && line.ends_with("(details: --verbose)."),
+ "{line}"
+ );
}
}
}
diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs
--- a/crates/socket-patch-cli/src/commands/scan/policy.rs
+++ b/crates/socket-patch-cli/src/commands/scan/policy.rs
@@ -11,9 +11,9 @@
use socket_patch_core::api::types::PatchSearchResult;
use socket_patch_core::manifest::schema::PatchManifest;
use socket_patch_core::policy::{
- canon, find_repo_root_with_warnings, policy_block, FilteredEntry, RetainedEntry, patch_severity_order, repo_relative_checked, sanitize, severity_name,
- DiskPolicyFs, FilterReason, Offers, PolicyError, PolicySource, PolicyWarning, Root, SelectionPolicy,
- PATCHES_DISABLED,
+ canon, find_repo_root_with_warnings, patch_severity_order, policy_block, repo_relative_checked,
+ sanitize, severity_name, DiskPolicyFs, FilterReason, FilteredEntry, Offers, PolicyError,
+ PolicySource, PolicyWarning, RetainedEntry, Root, SelectionPolicy, PATCHES_DISABLED,
};
use socket_patch_core::utils::purl::normalize_purl;
@@ -42,12 +42,18 @@
/// Load the policy for `args` (4.5): `--global` scans have no repo and read
/// no file; everything else reads the repo root's socket.yml.
pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result<InvocationPolicy, PolicyLoadError> {
- let overrides = args.socket_yml.overrides().map_err(PolicyLoadError::Usage)?;
+ let overrides = args
+ .socket_yml
+ .overrides()
+ .map_err(PolicyLoadError::Usage)?;
let cwd = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone());
if args.common.is_global() {
- let policy = SelectionPolicy::load(&socket_patch_core::policy::MemoryPolicyFs::default(), &overrides)
- .map_err(PolicyLoadError::Policy)?
- .0;
+ let policy = SelectionPolicy::load(
+ &socket_patch_core::policy::MemoryPolicyFs::default(),
+ &overrides,
+ )
+ .map_err(PolicyLoadError::Policy)?
+ .0;
return Ok(InvocationPolicy {
policy,
repo_root: cwd,
@@ -56,8 +62,8 @@
});
}
let (repo_root, mut warnings) = find_repo_root_with_warnings(&cwd);
- let (policy, load_warnings) =
- SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides).map_err(PolicyLoadError::Policy)?;
+ let (policy, load_warnings) = SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides)
+ .map_err(PolicyLoadError::Policy)?;
warnings.extend(load_warnings);
Ok(InvocationPolicy {
policy,
@@ -138,7 +144,12 @@
impl ScanPolicy {
/// The policy for the project rooted at `root_dir`.
- pub(crate) fn for_root(invocation: &InvocationPolicy, root_dir: &Path, explicit: bool, global: bool) -> Self {
+ pub(crate) fn for_root(
+ invocation: &InvocationPolicy,
+ root_dir: &Path,
+ explicit: bool,
+ global: bool,
+ ) -> Self {
let root_dir = std::fs::canonicalize(root_dir).unwrap_or_else(|_| root_dir.to_path_buf());
let project = repo_relative_checked(&invocation.repo_root, &root_dir).unwrap_or_default();
let root_verdict = if global {
@@ -171,7 +182,9 @@
severity: None,
});
}
- let announce_warnings = !invocation.warned.swap(true, std::sync::atomic::Ordering::Relaxed);
+ let announce_warnings = !invocation
+ .warned
+ .swap(true, std::sync::atomic::Ordering::Relaxed);
Self {
policy: invocation.policy.clone(),
warnings,
@@ -224,7 +237,10 @@
/// exclude stays in the query (so `upgradeAvailable` can be reported)
/// but joins the retained set, which never reaches a writer.
pub(crate) fn admit_crawled(&self, purl: &str) -> bool {
- let verdict = self.root_verdict.clone().and_then(|()| self.policy.admits_purl(purl));
+ let verdict = self
+ .root_verdict
+ .clone()
+ .and_then(|()| self.policy.admits_purl(purl));
let reason = match verdict {
Ok(()) => return true,
Err(reason) => reason,
@@ -334,7 +350,8 @@
// (not when a lower-ranked admitted patch simply wins).
let top_withheld = self.policy.admits_severity(patch_severity_order(&group[0]));
if let Err(reason) = top_withheld {
- let upgrade_withheld = chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
+ let upgrade_withheld =
+ chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
if chosen.is_none() || upgrade_withheld {
report.filtered.push(FilteredEntry {
purl: Some(canon(&purl)),
@@ -522,17 +539,20 @@
let verdict = if !policy.enabled() {
Err(FilterReason::Disabled)
} else {
- root_verdict.clone().and_then(|()| policy.admits_purl(purl)).and_then(|()| {
- // The floor only hides a package when none of its patches pass.
- match group
- .iter()
- .map(|p| policy.admits_severity(patch_severity_order(p)))
- .find(Result::is_ok)
- {
- Some(ok) => ok,
- None => policy.admits_severity(patch_severity_order(group[0])),
- }
- })
+ root_verdict
+ .clone()
+ .and_then(|()| policy.admits_purl(purl))
+ .and_then(|()| {
+ // The floor only hides a package when none of its patches pass.
+ match group
+ .iter()
+ .map(|p| policy.admits_severity(patch_severity_order(p)))
+ .find(Result::is_ok)
+ {
+ Some(ok) => ok,
+ None => policy.admits_severity(patch_severity_order(group[0])),
+ }
+ })
};
if let Err(reason) = verdict {
out.push((
diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs
@@ -4,8 +4,10 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
-use socket_patch_core::rollout::{canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan};
pub(crate) use socket_patch_core::rollout::stage::*;
+use socket_patch_core::rollout::{
+ canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan,
+};
use super::discovery::UpdateInfo;
@@ -208,11 +210,11 @@
mod tests {
use super::*;
use socket_patch_core::api::types::PatchSearchResult;
+ use socket_patch_core::api::types::VulnerabilityResponse;
use socket_patch_core::manifest::schema::PatchManifest;
- use std::path::Path;
- use socket_patch_core::api::types::VulnerabilityResponse;
use socket_patch_core::manifest::schema::PatchRecord;
use std::collections::HashMap;
+ use std::path::Path;
fn offer(purl: &str, uuid: &str, published: &str, severities: &[&str]) -> PatchSearchResult {
PatchSearchResult {
@@ -357,13 +359,21 @@
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"])],
+ &[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 },
+ &MaxNew {
+ value: Some(0),
+ source: MaxNewSource::Flag,
+ },
false,
&BTreeSet::new(),
);
diff --git a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
@@ -1,7 +1,6 @@
//! `scan --max-new-patches` (see the rollout guide,
//! `docs/configuration.md#gradual-rollout`).
-
use clap::Args;
pub(crate) use socket_patch_core::rollout::stage::RolloutCarry;
use socket_patch_core::rollout::{resolve_max_new, MaxNew};
@@ -77,7 +76,6 @@
}
}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs
--- a/crates/socket-patch-cli/src/commands/vendor.rs
+++ b/crates/socket-patch-cli/src/commands/vendor.rs
@@ -442,10 +442,7 @@
/// entry (fail-safe): ecosystems other than npm, cargo and pypi (whose
/// probe covers the requirements flavor only) have no in-use probe yet,
/// and a missing/unreadable lockfile proves nothing.
-pub(crate) async fn dispatch_in_use_one(
- entry: &VendorEntry,
- project_root: &Path,
-) -> Option<bool> {
+pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option<bool> {
match entry.ecosystem.as_str() {
"npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await,
// Cargo probes the lock entry's shape: detached + `[patch]` pointing
@@ -1237,8 +1234,7 @@
// know (the ledger was ignored or dropped from the commit along with the
// manifest) leaves every fresh install failing; the manifest keys above
// cannot see it, so the references are read from the wiring itself.
- let references =
- crate::commands::vendored_backend::repair::scan_vendor_references(root).await;
+ let references = crate::commands::vendored_backend::repair::scan_vendor_references(root).await;
for (eco, uuid, rel) in references {
let ledgered = state
.entries
diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs
--- a/crates/socket-patch-cli/tests/apply/apply_network.rs
+++ b/crates/socket-patch-cli/tests/apply/apply_network.rs
@@ -940,7 +940,10 @@
"a legacy package archive must not cover the patch; stdout={stdout}\nstderr={stderr}"
);
let content = std::fs::read(tmp.path().join("node_modules/pkgcache/index.js")).unwrap();
- assert_eq!(content, before, "the file must not be patched from the legacy archive");
+ assert_eq!(
+ content, before,
+ "the file must not be patched from the legacy archive"
+ );
let requests = mock.received_requests().await.unwrap_or_default();
let blob_path = format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}");
@@ -1043,10 +1046,7 @@
v["summary"]["applied"], 1,
"the drifted nested copy must be warn-overwritten.\nstdout={v:#}"
);
- assert_eq!(
- v["summary"]["failed"], 0,
- "no copy may fail.\nstdout={v:#}"
- );
+ assert_eq!(v["summary"]["failed"], 0, "no copy may fail.\nstdout={v:#}");
// The nested copy's blob was fetched on demand…
let requests = mock.received_requests().await.unwrap();
diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
--- a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
+++ b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
@@ -201,7 +201,9 @@
"non-silent stderr must carry the {CODE} warning; got:\n{stderr}"
);
assert_eq!(
- stderr.matches("Warning: bundler app config BUNDLE_PATH").count(),
+ stderr
+ .matches("Warning: bundler app config BUNDLE_PATH")
+ .count(),
1,
"exactly ONE warning line (not one per discovery call); got:\n{stderr}"
);
diff --git a/crates/socket-patch-cli/tests/cli/covgap_output.rs b/crates/socket-patch-cli/tests/cli/covgap_output.rs
--- a/crates/socket-patch-cli/tests/cli/covgap_output.rs
+++ b/crates/socket-patch-cli/tests/cli/covgap_output.rs
@@ -168,9 +168,8 @@
.expect("spawn socket-patch in PTY");
drop(pair.slave);
- let reader_handle = crate::pty_io::PtyOutput::spawn(
- pair.master.try_clone_reader().expect("clone reader"),
- );
+ let reader_handle =
+ crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
// Watchdog: detached kill after `timeout`; a no-op if the child exits
// naturally first.
@@ -261,7 +260,10 @@
"\n",
Duration::from_secs(15),
);
- assert_eq!(code, 0, "remove with bare Enter must succeed; got: {output}");
+ assert_eq!(
+ code, 0,
+ "remove with bare Enter must succeed; got: {output}"
+ );
// The interactive confirm MUST have run — otherwise this test passes
// vacuously against a regression that drops the TTY gate and
// auto-proceeds. Match the distinctive prompt verbatim (the loose
diff --git a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
--- a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
+++ b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
@@ -112,9 +112,8 @@
// closed. The previous design used a chunked read+mpsc loop
// because it interleaved with a try_wait poll; the simplified
// design serializes wait → drop master → read_to_end joins.
- let reader_handle = crate::pty_io::PtyOutput::spawn(
- pair.master.try_clone_reader().expect("clone reader"),
- );
+ let reader_handle =
+ crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
// Watchdog: detach a thread that kills the child after `timeout`.
// The cloned ChildKiller is independent of the main `child`
diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs
--- a/crates/socket-patch-cli/tests/cli_config_fallback.rs
+++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs
@@ -59,8 +59,7 @@
let mut cmd = Command::new(BINARY);
// Human mode: core's proxy advisory (the oracle below) is muted under
// `--json`/`--silent`.
- cmd.args(["scan", "-e", "npm", "--cwd"])
- .arg(project);
+ cmd.args(["scan", "-e", "npm", "--cwd"]).arg(project);
for (key, _) in std::env::vars_os() {
let name = key.to_string_lossy();
if name.starts_with("SOCKET_") {
@@ -298,7 +297,9 @@
json_cmd.arg("--json");
let json_out = run(json_cmd);
assert!(
- json_out.stderr.contains("could not parse socket-cli config"),
+ json_out
+ .stderr
+ .contains("could not parse socket-cli config"),
"the parse warning must reach stderr under --json too; got:\n{}",
json_out.stderr
);
diff --git a/crates/socket-patch-cli/tests/cli_get_silent.rs b/crates/socket-patch-cli/tests/cli_get_silent.rs
--- a/crates/socket-patch-cli/tests/cli_get_silent.rs
+++ b/crates/socket-patch-cli/tests/cli_get_silent.rs
@@ -25,10 +25,7 @@
for var in GLOBAL_ARG_ENV_VARS {
cmd.env_remove(var);
}
- for var in [
- "SOCKET_SAVE_ONLY",
- "SOCKET_ALL_RELEASES",
- ] {
+ for var in ["SOCKET_SAVE_ONLY", "SOCKET_ALL_RELEASES"] {
cmd.env_remove(var);
}
cmd.env("SOCKET_TELEMETRY_DISABLED", "1");
diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs
--- a/crates/socket-patch-cli/tests/cli_parse_list.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_list.rs
@@ -370,7 +370,11 @@
let out = run_list_binary(tmp.path(), &["--json"]);
let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim())
.expect("stdout must be valid JSON envelope");
- assert_eq!(out.status.code(), Some(0), "missing manifest is an empty list");
+ assert_eq!(
+ out.status.code(),
+ Some(0),
+ "missing manifest is an empty list"
+ );
assert_eq!(v["status"], "success", "envelope: {v}");
assert_eq!(v["summary"]["discovered"], 0, "envelope: {v}");
}
@@ -1313,7 +1317,10 @@
assert_eq!(v["status"], "success", "envelope={v}");
let warnings = v["warnings"].as_array().expect("warnings[] present");
assert_eq!(warnings.len(), 1, "envelope={v}");
- assert_eq!(warnings[0]["code"], "redirect_ledger_corrupt", "envelope={v}");
+ assert_eq!(
+ warnings[0]["code"], "redirect_ledger_corrupt",
+ "envelope={v}"
+ );
assert!(
out.stderr.is_empty(),
"--json must keep stderr clean: {}",
diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs
--- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs
@@ -366,7 +366,11 @@
/// relied on the rejection get a test-visible flip instead of a silent one.
#[test]
fn multiple_targets_parse_in_order() {
- let args = parse_rollback(&["pkg:npm/foo@1", "packages/api/**", "b0630680-4da6-45f9-bba8-b888e0ffd58c"]);
+ let args = parse_rollback(&[
+ "pkg:npm/foo@1",
+ "packages/api/**",
+ "b0630680-4da6-45f9-bba8-b888e0ffd58c",
+ ]);
assert_eq!(
args.targets,
vec![
diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs
--- a/crates/socket-patch-cli/tests/cli_parse_scan.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs
@@ -898,7 +898,11 @@
("NONE", None),
] {
let args = parse_scan(&["--max-new-patches", raw]);
- assert_eq!(args.rollout.max_new_patches, Some(MaxNewPatches(want)), "{raw}");
+ assert_eq!(
+ args.rollout.max_new_patches,
+ Some(MaxNewPatches(want)),
+ "{raw}"
+ );
}
}
@@ -989,20 +993,33 @@
assert_eq!(parse_scan(&[]).socket_yml.min_severity, None);
assert_eq!(overrides(&[], &[]).unwrap().min_severity, None);
assert_eq!(
- overrides(&["--min-severity", "High"], &[]).unwrap().min_severity,
+ overrides(&["--min-severity", "High"], &[])
+ .unwrap()
+ .min_severity,
Some((Some(1), OverrideSource::Flag))
);
assert_eq!(
- overrides(&["--min-severity", "none"], &[("SOCKET_MIN_SEVERITY", "critical")]).unwrap().min_severity,
+ overrides(
+ &["--min-severity", "none"],
+ &[("SOCKET_MIN_SEVERITY", "critical")]
+ )
+ .unwrap()
+ .min_severity,
Some((None, OverrideSource::Flag))
);
assert_eq!(
- overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")]).unwrap().min_severity,
+ overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")])
+ .unwrap()
+ .min_severity,
Some((Some(2), OverrideSource::Env))
);
- assert_eq!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "")]).unwrap().min_severity, None);
+ assert_eq!(
+ overrides(&[], &[("SOCKET_MIN_SEVERITY", "")])
+ .unwrap()
+ .min_severity,
+ None
+ );
assert!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "severe")]).is_err());
assert!(try_parse_scan(&["--min-severity", "severe"]).is_err());
... diff truncated: showing 800 of 3318 linesYou can send follow-ups to the cloud agent here.
# Conflicts: # crates/socket-patch-cli/CLI_CONTRACT.md
|
bugbot run Generated by Claude Code |
- remove/rollback: the ambiguity refusal now counts a vendor-ledger entry under its decoded base purl when the target reaches it that way. A golang key is case-encoded (`!core`), so a last-segment `core` missed the key, skipped the refusal, and still selected the entry through base_purl. One purl per entry, so an encoded key and its base never count as two. - Bare-UUID shortcut: a UUID-shaped value of a value-taking flag (`--org <UUID>`, `-o <UUID>`, `--api-token <UUID>`) is no longer taken as the shortcut operand. `socket-patch --org <UUID> scan` used to parse as `get scan`; it now fails the same way a non-UUID org value does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 99033bd. Configure here.
|
[agent] Blocked: CI is still running on 99033bd, held up by the macOS/Windows runner backlog. Nothing has failed. CI at 21:21Z, 555 check runs: 409 success, 6 skipped, 0 failed, 139 queued, 1 in progress. Almost all of the queued jobs are macOS/Windows Changes this pass:
Bugbot: the run on 99033bd succeeded with no new findings. Both earlier threads have replies and are resolved. Next: once every check on 99033bd is green and Generated by Claude Code |
|
[agent] Ready for review at
Generated by Claude Code |
Resolve conflicts with main's generation-blind matching (#999) and the PurlKey identity rewrite: - ledgers: `Ledgers::matching` and the core `hosted_pins_matching` now take a `&Target`, keeping main's claim-group and matched-manifest-key generation spans; the remove-local `hosted_pins_matching` copy is dropped in favour of the core one. - remove/rollback: call sites pass the parsed target; rollback's name ambiguity check now also covers the claim-group hosted pins. - vendor/state, get: union of imports (PurlKey + Target); the deleted `patch_matches` is gone from main's new purl test too. - CLI_CONTRACT: keep the target-grammar wording plus main's claim-group sentence. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| /// | ||
| /// `get`, `remove` and `rollback` refuse an ambiguous name instead of | ||
| /// acting on every package it reaches by last segment. | ||
| pub fn ambiguity<'a>(&self, purls: impl IntoIterator<Item = &'a str>) -> Option<String> { |
There was a problem hiding this comment.
[agent] ambiguity filters through name_matches → package_spec_matches, which matches a bare name against the full name or the last segment. It never prefers an exact full-name match. So get lodash in a project that also has @types/lodash installed counts two packages (pkg:npm/lodash by full name, pkg:npm/@types/lodash by last segment) and exits 1 with "use the full name or a purl", even though the full name was typed. That hits react, express, node and most typical TS repos, including the PR's own headline example. ambiguity_counts_distinct_packages only uses scoped @x/core purls with no unscoped core, so it doesn't catch this. If any purl matches by full name, keeping only those before counting identities would fix it.

Problem
Architecture audit §3.B / UX §4: the CLI had four separate grammars for "which package".
lodashwas an exact match inscan, a fuzzy match ingetand an error inremove/rollback. A versionless purl was rejected byremove, and the<UUID>shortcut only worked when the UUID was argv[1].get <name>ranfuzzy_match_packagesand searched onlymatches[0].purl.get yamlpatchedyaml-ast-parser, andget lodashsearched only the top-levellodash@4.17.21, never the nested vulnerable@4.17.4. Hosted and vendored runs did this without a prompt.getignored--ecosystemswhen selecting. Only the nested agent apply was scoped, soget CVE-X -e npmalso rewrote, vendored or recorded the advisory's PyPI patch.get <uuid>path and the search path applied different selection rules.get <uuid>overrides socket.yml without the documentedpolicy_bypassedwarning (purl/CVE/GHSA forms do warn) #453):get <uuid>dispatched before the onlypolicy_bypass_warningscall, so it overrode socket.yml silently in every mode.get <uuid>overrides socket.yml without the documentedpolicy_bypassedwarning (purl/CVE/GHSA forms do warn) #453 was mislabelledpm:pipenv; it is now relabelledarch-auditwith a comment.Fixes #453
Change
One core parser,
socket_patch_core::utils::target:Target::parseclassifies a token as one of:CVE-…/GHSA-…(any case)pkg:purlTarget::matches_packagematches installed packages.Target::matches_patchmatches manifest records, vendor-ledger entries and hosted pins.policy::package_spec_matches, the matcherscan --packageand socket.yml already use. That means full name or last segment, case-insensitive, and PEP 503 for PyPI. A name never matches by prefix or substring.Target::ambiguity): the last-segment rule can reach several packages (corereaches@angular/coreand@babel/core).get,removeandrollbackact on one package per name, so they refuse such a name with exit 1 before they search or change anything. The message names each package as a versionless purl and asks for the full name or a purl.remove --jsonuses the newambiguous_targeterror code. Several versions of one package are not ambiguous.scan --packageand socket.yml keep selecting every package the name reaches.v2ingithub.com/x/y/v2) are never names, soget v2/remove v2no longer reach every v2+ module.?qualifiedpurl matches exactly one.Each verb now uses this parser:
getMatched: …. The per-version searches run concurrently (ordered_concurrent/api_concurrency_for). A failed search still fails the run, as the single search did, so the run never acts on partial results that silently miss the failed version. With no exact match it returnsno_match(exit 0, no API call) and, in human mode, printsDid you mean: …?built from the fuzzy ranker. Fuzzy results are only suggested, never searched or acted on.--ecosystemsscopes the package-name crawl and filters every search result.--ecosystems(not_found). The check runs before the paid gate, theFound patch for …line and thepatch_fetchedtelemetry event. It also emitspolicy_bypassedon stderr and in the JSON envelope in agent, hosted and vendored modes, including dry runs.remove/rollbackaccept a bare name and a versionless purl, which select every recorded version. Before, these were "No patch found".rollbacktreats an npm@scope/nameas a name, not a path glob. A relative slash token with no glob metacharacters or./..segments (composervendor/pkg, a go module path) counts as a name when it selects a recorded or hosted patch, and as a path glob only otherwise. Sorollback monolog/monologselects the same records asremove monolog/monolog. A non-UUID-shaped token is still compared to the recorded uuid verbatim, so non-canonical uuids stay addressable.socket-patch --json <UUID>works.Duplicate copies deleted
get::detect_identifier_type+IdentifierType+ CVE/GHSA regexes;rollback::classify_target's identifier arm)Target::parse; rollback keeps only its path-glob check,target::is_path_shaped)utils::purl::patch_matches;VendorEntry::matches_identifierwrapping it)Target::matches_patch)get;package_spec_matchesin scan/policy)package_spec_matchesviaTarget; fuzzy kept only for suggestions)looks_like_uuid, core clientis_valid_uuid)target::is_uuid_shaped)purl_has_versionTarget::is_versioned_purlcrawl_all_ecosystemscrawl_ecosystems#705 is only partly addressed:
path_safety::is_canonical_uuid,apply::is_safe_archive_uuidand python_script'sUuid::parse_strare stricter on-disk grammars and are untouched.Testing
Review round 1 added
tests/in_process_target_ambiguity.rs. Its 7 tests were run first against the previous branch head'sget.rs/remove.rs/rollback.rs/target.rs, where 6 failed. The 7th (rollbackv2) was then given ago.modso that it tells the old and new code apart. All 7 pass with the fix:remove_refuses_a_name_that_reaches_two_packages,rollback_refuses_a_name_that_reaches_two_packages,get_refuses_a_name_that_reaches_two_installed_packages:coreover@angular/coreand@babel/coreremove_never_treats_a_go_major_suffix_as_a_name,rollback_never_treats_a_go_major_suffix_as_a_name: two/v2modulesrollback_takes_a_slash_package_name_as_a_target: composermonolog/monologget_uuid_outside_the_ecosystems_sends_no_fetched_event: nopatch_fetchedtelemetry for a refused UUIDCore unit tests were also added:
ambiguity_counts_distinct_packages,go_major_suffix_is_never_a_nameandpackage_identity_drops_version_and_qualifiers.Round 1 also reverted the formatting-only hunks in
e2e_socket_yml_policy.rs; only the new test remains. It renamed stalecrawl_all_ecosystems/IdentifierType::Packagetest comments and fixed the CLI_CONTRACTno_matchparagraph and the rollback row (it now listspackage name). The branch has mergedorigin/mainup to 431b818. The conflicts were the #934superseded_by_hostedargument and its imports, and the #1025 PyPI-spelling text in CLI_CONTRACT (both sides kept). #1025's newpatch_matchesassertion inpurl.rsnow goes throughTarget::matches_patch.Re-run after the merge (macOS,
heavy-job.sh,CARGO_INCREMENTAL=0):cargo test -p socket-patch-core --lib -- utils::target ledgers policy purl: 281 passedcargo test -p socket-patch-cli --lib: 874 passedcargo test -p socket-patch-clion these targets:in_process_target_ambiguity,in_process_get,e2e_socket_yml_policy,get,remove,rollback,in_process_remove_repair_lifecycle,in_process_rollback_all_ecosystems,in_process_rollback_hosted,in_process_rollback_vendored,covgap_commands_get,covgap_commands_rollback,cli_parse_remove,cli_parse_rollback,cli_remove_silent,remove_rollback_api_overrides,coverage_fix_rollback_ecosystem_scoped_hosted. All passed.cargo clippy --workspace --all-features -- -D warnings, passes with the pinned 1.93.1 toolchain once-A unused_variablesis added. That one lint is a macOS-only finding in untouchedpython_crawler.rs:2734, acfg(not(macos))use; CI runs on Linux.--all-targetshas existing findings in untouched core test code and none in touched files.Round 2 (Bugbot)
Commit 99033bd fixes two Bugbot findings.
origin/mainis merged up to 05ecc6e.remove/rollbackcount a vendor-ledger entry under its decodedbase_purlwhen the target reaches it that way (VendorEntry::ambiguity_purl). A!core-keyed golang entry can no longer escape the refusal.socket-patch --org <UUID> scanno longer parses asget scan(fallback_skips_a_uuid_shaped_flag_value).cargo test -p socket-patch-cli --lib(875 passed), corevendor::state utils::target(38 passed),in_process_target_ambiguity,cli_parse_main,in_process_get_uuid_fallback,in_process_rollback_vendored,in_process_remove_repair_lifecycle,remove,rollback,cli_parse_remove,cli_parse_rollback: all passed.remove_lock_held_returned_then_proceeds_after_releasefailed once on lock-release timing, then passed 4 times in a row. The CI clippy command passes.Round 0 (initial PR)
All new regression tests were run first against unfixed
origin/mainsource and failed there, then passed with the change:in_process_get:get_package_name_searches_every_installed_version_exactly(B11, nested version plus prefix sibling)get_package_name_never_falls_back_to_a_near_name(B11, yaml-style)get_cve_selects_only_the_requested_ecosystems(B56)get_uuid_outside_the_requested_ecosystems_is_not_acted_on(B57)e2e_socket_yml_policy::get_by_uuid_bypasses_the_policy_with_a_warning_in_every_mode(B29/get <uuid>overrides socket.yml without the documentedpolicy_bypassedwarning (purl/CVE/GHSA forms do warn) #453)Unit tests:
utils::targettests, including the formerpatch_matchescontract carried over verbatimremove_by_name_or_versionless_purl_removes_every_versionclassify_target_uses_the_shared_grammarfallback_rewrites_a_uuid_after_leading_flagspackage_name_selects_every_exact_version_and_no_near_namesCommands run (macOS, through
heavy-job.sh,CARGO_INCREMENTAL=0):cargo build -p socket-patch-cli --testscargo test -p socket-patch-core --lib -- target:: purl:: ledgers:: vendor::state:: api::client:: policy:: crawlers::fuzzy: 300 passedcargo test -p socket-patch-cli --lib: 865 passedcargo test -p socket-patch-clion these integration targets:in_process_get,covgap_commands_get,e2e_socket_yml_policy,cli_parse_get,cli_parse_main,cli_parse_remove,cli_parse_rollback,cli_get_silent,cli_remove_silent,in_process_get_uuid_fallback,in_process_get_modes,in_process_get_hosted_ecosystems,in_process_get_manifest_path,in_process_remove_repair_lifecycle,covgap_commands_rollback,policy_pypi_names,remove_rollback_api_overrides,in_process_rollback_hosted,in_process_rollback_vendored,in_process_rollback_all_ecosystems,get,remove,rollback. All passed.cargo clippy -p socket-patch-core -p socket-patch-cli --all-targets: no findings in any touched file.-D warningsfails only on findings that already exist on main in untouched files with the local clippy (python_crawler, jvm_jar, apply, bun_binary and others).rustfmt --checkon every touched file. Formatting went through stdin, so untouched child modules were not reformatted.Docker and Linux-only suites are left to CI.
Deferred
vendorhas no per-package form, so there is nothing to route yet. When one is added it should take aTarget.scan --packagealready matches throughpackage_spec_matches. Rejecting UUID/CVE-shaped specs with a typed error would change exit codes, so it is left for the Decide: one shape for the--jsontop-levelerror(scan and get emit both a string and a {code, message} object) #704 exit-policy decision.fix/undocommand model) is a maintainer decision.Targetis the natural target type for it, but nothing here decides it.remove corewith only@babel/corerecorded removes it. That is the scan/socket.yml rule. Ambiguous names are refused, so the token can no longer reach a second package, but whetherremove/rollbackshould require the full name is left for a maintainer decision rather than decided here.get <name>: it still aborts the run, as the single search did. Warning and continuing would need the same decision as the narrowing skips.🤖 Generated with Claude Code
Note
Medium Risk
Changes user-visible CLI matching and selection behavior (get package names, ecosystem scoping, rollback targets) across manifest, vendor, and hosted state; well-tested but affects destructive commands and advisory fan-outs.
Overview
Introduces a shared target grammar in
socket_patch_core::utils::targetsoget,remove,rollback, and the bare-UUID argv shortcut all classify tokens the same way (UUID, CVE/GHSA, PURL, or exact package name). Duplicate identifier/matching logic in the CLI and core client is removed in favor ofTarget::matches_patch/is_uuid_shaped.getno longer fuzzy-picks one package for a name: it searches every installed version of an exact name, suggests near names only viaDid you mean, and refuses ambiguous last-segment names (exit 1 /ambiguous_targetonremove).--ecosystemsnow filters search results and UUID fetches before acting; UUIDgetalso surfacespolicy_bypassedlike other identifier paths.remove/rollbackaccept bare names and versionless PURLs; rollback promotes slash-shaped tokens (e.g. composer paths) to names when they match recorded/hosted patches instead of treating them as path globs. The<UUID>shortcut works when root flags precede the UUID (socket-patch --json <UUID>).Docs and
CLI_CONTRACT.mddocument the grammar, ambiguity rules, and new error code.Reviewed by Cursor Bugbot for commit 99033bd. Configure here.
Generated by Claude Code