Skip to content

Use one package target grammar for get, remove, rollback and the UUID shortcut - #1034

Open
Mikola Lysenko (mikolalysenko) wants to merge 11 commits into
mainfrom
arch-fix/target-grammar
Open

Mikola Lysenko (mikolalysenko) wants to merge 11 commits into
mainfrom
arch-fix/target-grammar

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Architecture audit §3.B / UX §4: the CLI had four separate grammars for "which package". lodash was an exact match in scan, a fuzzy match in get and an error in remove/rollback. A versionless purl was rejected by remove, and the <UUID> shortcut only worked when the UUID was argv[1].

Fixes #453

Change

One core parser, socket_patch_core::utils::target:

  • Target::parse classifies a token as one of:
    • a UUID (8-4-4-4-12 hex, any case)
    • CVE-… / GHSA-… (any case)
    • a pkg: purl
    • an exact package name, which is the fallback for any other token
  • Target::matches_package matches installed packages.
  • Target::matches_patch matches manifest records, vendor-ledger entries and hosted pins.
  • Name matching goes through policy::package_spec_matches, the matcher scan --package and 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.
  • Ambiguous names (Target::ambiguity): the last-segment rule can reach several packages (core reaches @angular/core and @babel/core). get, remove and rollback act 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 --json uses the new ambiguous_target error code. Several versions of one package are not ambiguous. scan --package and socket.yml keep selecting every package the name reaches.
  • Go major-version suffixes (v2 in github.com/x/y/v2) are never names, so get v2 / remove v2 no longer reach every v2+ module.
  • Purl matching:
    • A versioned purl keeps the existing release-variant rules: a base purl covers every variant, and a ?qualified purl matches exactly one.
    • A versionless purl selects every version.

Each verb now uses this parser:

  • get
    • B11: a name searches every installed version of the exact name and prints Matched: …. 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 returns no_match (exit 0, no API call) and, in human mode, prints Did you mean: …? built from the fuzzy ranker. Fuzzy results are only suggested, never searched or acted on.
    • B56: --ecosystems scopes the package-name crawl and filters every search result.
    • B29/B57: the UUID path skips a patch outside --ecosystems (not_found). The check runs before the paid gate, the Found patch for … line and the patch_fetched telemetry event. It also emits policy_bypassed on stderr and in the JSON envelope in agent, hosted and vendored modes, including dry runs.
  • remove / rollback accept a bare name and a versionless purl, which select every recorded version. Before, these were "No patch found". rollback treats an npm @scope/name as a name, not a path glob. A relative slash token with no glob metacharacters or ./.. segments (composer vendor/pkg, a go module path) counts as a name when it selects a recorded or hosted patch, and as a path glob only otherwise. So rollback monolog/monolog selects the same records as remove monolog/monolog. A non-UUID-shaped token is still compared to the recorded uuid verbatim, so non-canonical uuids stay addressable.
  • Bare-UUID shortcut: fires on the first UUID-shaped token before any subcommand name, so socket-patch --json <UUID> works.

Duplicate copies deleted

Grammar Before After
Identifier classification 2 (get::detect_identifier_type + IdentifierType + CVE/GHSA regexes; rollback::classify_target's identifier arm) 1 (Target::parse; rollback keeps only its path-glob check, target::is_path_shaped)
Record matcher 2 (utils::purl::patch_matches; VendorEntry::matches_identifier wrapping it) 1 (Target::matches_patch)
"Which installed package" 2 (fuzzy auto-pick in get; package_spec_matches in scan/policy) 1 (package_spec_matches via Target; fuzzy kept only for suggestions)
User-input UUID shape (C18/#705) 2 of the 5 (CLI looks_like_uuid, core client is_valid_uuid) 1 (target::is_uuid_shaped)
purl_has_version 1 private copy in get.rs moved into Target::is_versioned_purl
crawl_all_ecosystems wrapper beside crawl_ecosystems deleted

#705 is only partly addressed: path_safety::is_canonical_uuid, apply::is_safe_archive_uuid and python_script's Uuid::parse_str are 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's get.rs/remove.rs/rollback.rs/target.rs, where 6 failed. The 7th (rollback v2) was then given a go.mod so 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: core over @angular/core and @babel/core
  • remove_never_treats_a_go_major_suffix_as_a_name, rollback_never_treats_a_go_major_suffix_as_a_name: two /v2 modules
  • rollback_takes_a_slash_package_name_as_a_target: composer monolog/monolog
  • get_uuid_outside_the_ecosystems_sends_no_fetched_event: no patch_fetched telemetry for a refused UUID

Core unit tests were also added: ambiguity_counts_distinct_packages, go_major_suffix_is_never_a_name and package_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 stale crawl_all_ecosystems / IdentifierType::Package test comments and fixed the CLI_CONTRACT no_match paragraph and the rollback row (it now lists package name). The branch has merged origin/main up to 431b818. The conflicts were the #934 superseded_by_hosted argument and its imports, and the #1025 PyPI-spelling text in CLI_CONTRACT (both sides kept). #1025's new patch_matches assertion in purl.rs now goes through Target::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 passed
  • cargo test -p socket-patch-cli --lib: 874 passed
  • cargo test -p socket-patch-cli on 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.
  • The CI clippy command, cargo clippy --workspace --all-features -- -D warnings, passes with the pinned 1.93.1 toolchain once -A unused_variables is added. That one lint is a macOS-only finding in untouched python_crawler.rs:2734, a cfg(not(macos)) use; CI runs on Linux. --all-targets has existing findings in untouched core test code and none in touched files.

Round 2 (Bugbot)

Commit 99033bd fixes two Bugbot findings. origin/main is merged up to 05ecc6e.

  • Ambiguity with encoded vendor keys: remove/rollback count a vendor-ledger entry under its decoded base_purl when the target reaches it that way (VendorEntry::ambiguity_purl). A !core-keyed golang entry can no longer escape the refusal.
  • UUID shortcut and flag values: the shortcut skips values of value-taking flags, so socket-patch --org <UUID> scan no longer parses as get scan (fallback_skips_a_uuid_shaped_flag_value).
  • Re-run: cargo test -p socket-patch-cli --lib (875 passed), core vendor::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_release failed 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/main source and failed there, then passed with the change:

Unit tests:

  • core utils::target tests, including the former patch_matches contract carried over verbatim
  • remove_by_name_or_versionless_purl_removes_every_version
  • classify_target_uses_the_shared_grammar
  • fallback_rewrites_a_uuid_after_leading_flags
  • package_name_selects_every_exact_version_and_no_near_names

Commands run (macOS, through heavy-job.sh, CARGO_INCREMENTAL=0):

  • cargo build -p socket-patch-cli --tests
  • cargo test -p socket-patch-core --lib -- target:: purl:: ledgers:: vendor::state:: api::client:: policy:: crawlers::fuzzy: 300 passed
  • cargo test -p socket-patch-cli --lib: 865 passed
  • cargo test -p socket-patch-cli on 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 warnings fails 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 --check on every touched file. Formatting went through stdin, so untouched child modules were not reformatted.

Docker and Linux-only suites are left to CI.

Deferred

  • vendor has no per-package form, so there is nothing to route yet. When one is added it should take a Target.
  • scan --package already matches through package_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 --json top-level error (scan and get emit both a string and a {code, message} object) #704 exit-policy decision.
  • C34 (fix/undo command model) is a maintainer decision. Target is the natural target type for it, but nothing here decides it.
  • Validate patch UUIDs through one utils::uuid grammar instead of five #705: the remaining strict on-disk UUID grammars, listed above.
  • Maintainer question: last-segment matching on destructive verbs. A name that reaches exactly one package by its last segment is still accepted: remove core with only @babel/core recorded removes it. That is the scan/socket.yml rule. Ambiguous names are refused, so the token can no longer reach a second package, but whether remove/rollback should require the full name is left for a maintainer decision rather than decided here.
  • Partial search failure in 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::target so get, 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 of Target::matches_patch / is_uuid_shaped.

get no longer fuzzy-picks one package for a name: it searches every installed version of an exact name, suggests near names only via Did you mean, and refuses ambiguous last-segment names (exit 1 / ambiguous_target on remove). --ecosystems now filters search results and UUID fetches before acting; UUID get also surfaces policy_bypassed like other identifier paths.

remove / rollback accept 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.md document the grammar, ambiguity rules, and new error code.

Reviewed by Cursor Bugbot for commit 99033bd. Configure here.


Generated by Claude Code

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>
@mikolalysenko Mikola Lysenko (mikolalysenko) added the arch-refactor PR opened by the scheduled architecture refactor routine label Oct 7, 2026
Comment thread crates/socket-patch-cli/src/commands/get.rs Dismissed
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
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 7, 2026 16:34

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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 lines

You can send follow-ups to the cloud agent here.

Comment thread crates/socket-patch-cli/src/commands/remove.rs
# Conflicts:
#	crates/socket-patch-cli/CLI_CONTRACT.md
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread crates/socket-patch-cli/src/lib.rs
- 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>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[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 native, capstone, install-proof and gradle matrix legs, and they are finishing at about 1 per 10 minutes. ci-ok hasn't reported yet, so I haven't added "Ready for review".

Changes this pass:

  • Merged origin/main (05ecc6e, Run CI on the merge queue and stop cancelling main push runs #1018 merge-queue CI). It merged cleanly and touches only CI files.
  • 99033bd fixes two Bugbot findings:
    • remove/rollback ambiguity now counts a vendor entry under its decoded base_purl. Before, a golang !core key escaped the refusal.
    • The bare-UUID shortcut skips the values of value-taking flags. Before, socket-patch --org <UUID> scan parsed as get scan.
  • Both have regression tests. Local runs passed: cli lib (875 tests), core vendor::state/utils::target, the target/remove/rollback/parse integration targets, and the CI clippy command.

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 ci-ok passes, add "Ready for review". If a job fails, read its log and decide between a rerun (flake) and a fix.

Generated by Claude Code

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 7, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Ready for review at 99033bdb67107c467026e635ab25cb03d8ccf9de.

  • CI: required ci-ok green (22:18Z). 547 success / 6 skipped / 0 failing of 557 check runs; 4 non-required macOS legs still queued on the runner backlog.
  • Bugbot reviewed 99033bdb67; all review threads resolved.
  • Mergeable, no conflicts, up to date with main.
  • Reviewer focus: the shared package-target grammar now used by get, remove, rollback and the UUID shortcut.

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> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 8, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-refactor PR opened by the scheduled architecture refactor routine

Projects

None yet

Development

Successfully merging this pull request may close these issues.

get <uuid> overrides socket.yml without the documented policy_bypassed warning (purl/CVE/GHSA forms do warn)

3 participants