Skip to content

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

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

Mikola Lysenko (mikolalysenko) wants to merge 18 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
User-visible CLI semantics change how names, versionless PURLs, and ambiguous targets resolve across patch fetch and rollback paths; mistakes could target the wrong package or refuse previously accepted fuzzy names.

Overview
Introduces a shared target grammar (socket_patch_core::utils::target::Target) so get, remove, rollback, and the bare-UUID argv shortcut classify package/patch tokens the same way. Identifiers are parsed as UUID, CVE/GHSA, PURL, or exact package name (same rules as scan --package / socket.yml)—not fuzzy prefix matches.

get now searches every installed version of an exact name (concurrent API calls), suggests near names only via Did you mean, refuses ambiguous last-segment names with ambiguous_target, honors --ecosystems on all identifier paths (including UUID), and surfaces policy_bypassed for UUID fetches like other modes.

remove / rollback accept bare names and versionless PURLs across manifest, vendor ledger, and hosted pins; ambiguous names fail the same way. rollback treats slash tokens like Composer/Go paths as package targets when they match recorded state, not only as path globs.

The <UUID> shortcut rewrites when the first operand (before any subcommand) is UUID-shaped, including after root flags (--json <UUID>), without treating UUID-shaped flag values as operands. Docs (README, CLI_CONTRACT) and tests are updated; duplicate identifier/UUID helpers in the CLI are removed in favor of core Target / is_uuid_shaped.

Reviewed by Cursor Bugbot for commit ca475de. 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.

Stale Bugbot comment from a previous run.

@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>
Comment thread crates/socket-patch-core/src/utils/target.rs Outdated
@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 8, 2026
Conflict in crates/socket-patch-cli/src/commands/get.rs only. Main's
#1043 moved get's agent download engine (DownloadParams/DownloadRun,
filter_to_installed_releases, download_patch_records_*, ...) into the
new commands/agent_download.rs and re-exports its entry points from
get. This branch did not change any of the moved code, so the
resolution takes main's get.rs and re-applies this branch's get.rs
hunks onto it: Target-based identifier classification and
forced_identifier_error, the exact-name per-version search fan-out,
--ecosystems filtering, the UUID policy_bypassed path, the deleted
purl_has_version / IdentifierType / CVE/GHSA regexes, and the test
updates.

Imports: dropped regex, is_purl, fmt and LazyLock (no longer used),
added Target/TargetKind, crawl_ecosystems (crawl_all_ecosystems is
deleted on this branch), and kept StreamExt, hold_back_debug and
ordered_concurrent/api_concurrency_for for the concurrent per-version
name search, which main's get.rs no longer imported after the move.
All other files auto-merged.

Co-Authored-By: Claude <noreply@anthropic.com>
@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.

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: Exact name treated as ambiguous
    • Modified Target::ambiguity to prefer exact full-name matches over last-segment hits, so typing 'lodash' now selects pkg:npm/lodash without being ambiguous with @types/lodash.

Create PR

Or push these changes by commenting:

@cursor push 32f77564db
Preview (32f77564db)
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
@@ -237,7 +237,9 @@
     }
 
     fn recorded_uuid(&self, purl: &str) -> Option<&str> {
-        self.recorded.get(&PurlKey::new(purl).into_string()).map(String::as_str)
+        self.recorded
+            .get(&PurlKey::new(purl).into_string())
+            .map(String::as_str)
     }
 
     /// Step 3: the root, ecosystem and package filters. Returns whether the
@@ -269,7 +271,10 @@
         }
         if self.root_verdict.is_err() {
             // Already reported as the root's one entry.
-        } else if report.filtered_purls.insert(PurlKey::new(purl).into_string()) {
+        } else if report
+            .filtered_purls
+            .insert(PurlKey::new(purl).into_string())
+        {
             report.filtered.push(FilteredEntry {
                 purl: Some(PurlKey::new(purl).into_string()),
                 uuid: None,
@@ -284,7 +289,10 @@
     /// Record the purls with a newer patch (`updates[]`), for
     /// `retained[].upgradeAvailable`.
     pub(crate) fn set_update_purls<'a>(&self, purls: impl IntoIterator<Item = &'a str>) {
-        self.report().update_purls = purls.into_iter().map(|p| PurlKey::new(p).into_string()).collect();
+        self.report().update_purls = purls
+            .into_iter()
+            .map(|p| PurlKey::new(p).into_string())
+            .collect();
     }
 
     /// Steps 5-6: group the tier-accessible offers, keep retained packages
@@ -298,7 +306,10 @@
         {
             let report = self.report();
             for offer in accessible {
-                if report.retained_purls.contains(&PurlKey::new(&offer.purl).into_string()) {
+                if report
+                    .retained_purls
+                    .contains(&PurlKey::new(&offer.purl).into_string())
+                {
                     continue;
                 }
                 grouped.entry(offer.purl.clone()).or_default().push(offer);

diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs
--- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs
+++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs
@@ -900,7 +900,10 @@
         return;
     };
     assert!(
-        fx.proj.join("mirror").join(format!("{DEP}-{DEP_VERSION}.tgz")).is_file(),
+        fx.proj
+            .join("mirror")
+            .join(format!("{DEP}-{DEP_VERSION}.tgz"))
+            .is_file(),
         "the fixture install must populate the offline mirror"
     );
     let fresh = fx.tmp.path().join("fresh");
@@ -926,7 +929,11 @@
             String::from_utf8_lossy(&ci.stderr)
         );
         assert!(
-            !fresh.join("node_modules").join(DEP).join("index.js").exists(),
+            !fresh
+                .join("node_modules")
+                .join(DEP)
+                .join("index.js")
+                .exists(),
             "yarn < 1.7 is expected to install nothing from the mirror"
         );
         return;
@@ -948,7 +955,10 @@
         );
         let installed =
             std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap();
-        assert_eq!(installed, fx.orig, "the untouched lock installs the upstream bytes");
+        assert_eq!(
+            installed, fx.orig,
+            "the untouched lock installs the upstream bytes"
+        );
         std::fs::remove_dir_all(fresh.join("node_modules")).unwrap();
     }
 }

diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs
--- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs
+++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs
@@ -729,7 +729,14 @@
 
 fn git(cwd: &Path, args: &[&str]) -> Output {
     let out = Command::new("git")
-        .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"])
+        .args([
+            "-c",
+            "user.name=t",
+            "-c",
+            "user.email=t@t",
+            "-c",
+            "init.defaultBranch=main",
+        ])
         .args(args)
         .current_dir(cwd)
         .output()
@@ -810,16 +817,30 @@
     };
     let (code, stdout, stderr) = run_socket(
         &proj,
-        &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()],
+        &[
+            "vendor",
+            "--json",
+            "--offline",
+            "--cwd",
+            proj.to_str().unwrap(),
+        ],
     );
-    assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}");
+    assert_eq!(
+        code, 0,
+        "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
+    );
 
     git(&proj, &["add", "-A"]);
     git(&proj, &["commit", "-qm", "vendored"]);
     let fresh = tmp.path().join("fresh");
     git(
         tmp.path(),
-        &["clone", "-q", proj.to_str().unwrap(), fresh.to_str().unwrap()],
+        &[
+            "clone",
+            "-q",
+            proj.to_str().unwrap(),
+            fresh.to_str().unwrap(),
+        ],
     );
     let fresh_global = tmp.path().join("fresh-yarn-global");
     let ci = corepack(
@@ -854,14 +875,26 @@
     let pkg_before = std::fs::read(proj.join("package.json")).unwrap();
     let (code, stdout, stderr) = run_socket(
         &proj,
-        &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()],
+        &[
+            "vendor",
+            "--json",
+            "--offline",
+            "--cwd",
+            proj.to_str().unwrap(),
+        ],
     );
-    assert_eq!(code, 1, "vendor must fail.\nstdout:\n{stdout}\nstderr:\n{stderr}");
+    assert_eq!(
+        code, 1,
+        "vendor must fail.\nstdout:\n{stdout}\nstderr:\n{stderr}"
+    );
     assert!(
         stdout.contains("vendor_artifact_gitignored"),
         "refusal code expected:\n{stdout}"
     );
     assert_eq!(std::fs::read(proj.join("yarn.lock")).unwrap(), lock_before);
-    assert_eq!(std::fs::read(proj.join("package.json")).unwrap(), pkg_before);
+    assert_eq!(
+        std::fs::read(proj.join("package.json")).unwrap(),
+        pkg_before
+    );
     assert!(!proj.join(format!(".socket/vendor/npm/{UUID}")).exists());
 }

diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs
--- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs
+++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs
@@ -556,8 +556,7 @@
 #[tokio::test]
 #[serial]
 async fn platform_wheel_is_not_pinned_into_the_lock() {
-    assert_wheel_tag_is_not_pinned("cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64")
-        .await;
+    assert_wheel_tag_is_not_pinned("cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64").await;
 }
 
 /// #1048: a pure wheel bound to one interpreter (`cp311-none-any`) fails

diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
--- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
+++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
@@ -434,7 +434,10 @@
     assert_eq!(code, 0, "scan --mode hosted should succeed on a BOM lock");
     let lock = std::fs::read_to_string(&lock_path).unwrap();
     assert!(lock.starts_with("\u{feff}lockfileVersion:"), "{lock}");
-    assert!(lock.contains(HOSTED_URL), "the BOM lock is redirected: {lock}");
+    assert!(
+        lock.contains(HOSTED_URL),
+        "the BOM lock is redirected: {lock}"
+    );
     let ws_path = tmp.path().join("pnpm-workspace.yaml");
     assert_eq!(
         std::fs::read_to_string(&ws_path).ok().as_deref(),
@@ -449,7 +452,10 @@
         pristine,
         "rollback restores the BOM lock byte for byte"
     );
-    assert!(!ws_path.exists(), "the auto-created workspace file goes too");
+    assert!(
+        !ws_path.exists(),
+        "the auto-created workspace file goes too"
+    );
 
     // A BOM workspace file whose first key is the user's opt-out: left
     // byte-identical (no duplicate `trustLockfile`), lock still redirected.
@@ -475,7 +481,11 @@
             "the lock is still redirected for {user_ws:?}"
         );
         let ws = std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap();
-        assert_eq!(ws, want.unwrap_or(user_ws), "workspace file for {user_ws:?}");
+        assert_eq!(
+            ws,
+            want.unwrap_or(user_ws),
+            "workspace file for {user_ws:?}"
+        );
         assert_eq!(ws.matches("trustLockfile").count(), 1, "{ws:?}");
     }
 }

diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs
--- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs
+++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs
@@ -1195,9 +1195,18 @@
     set_mode(0o755);
     assert_eq!(code, 1, "{env:#}");
     assert_eq!(env["status"], "error", "{env:#}");
-    assert!(!env.to_string().contains("redirect_takeover_unpatched"), "{env:#}");
-    assert_eq!(std::fs::read(root.join("requirements.txt")).unwrap(), vendored);
-    assert_eq!(std::fs::read(root.join(".socket/vendor/state.json")).unwrap(), state);
+    assert!(
+        !env.to_string().contains("redirect_takeover_unpatched"),
+        "{env:#}"
+    );
+    assert_eq!(
+        std::fs::read(root.join("requirements.txt")).unwrap(),
+        vendored
+    );
+    assert_eq!(
+        std::fs::read(root.join(".socket/vendor/state.json")).unwrap(),
+        state
+    );
     assert!(root.join(format!(".socket/vendor/pypi/{UUID}")).exists());
 }
 

diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs
@@ -300,8 +300,7 @@
     let content = crate::utils::fs::read_regular_to_string_sync(&cargo_toml_path).ok()?;
 
     // Fallback: parse directory name as <name>-<version>
-    package_name_version(&content)
-        .or_else(|| CargoCrawler::parse_dir_name_version(dir_name))
+    package_name_version(&content).or_else(|| CargoCrawler::parse_dir_name_version(dir_name))
 }
 
 /// SECURITY: `find_by_purls` formats name/version into a `<name>-<version>`

diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs
--- a/crates/socket-patch-core/src/formats/mod.rs
+++ b/crates/socket-patch-core/src/formats/mod.rs
@@ -29,8 +29,8 @@
 pub(crate) mod bun;
 pub mod cargo;
 pub mod composer;
+pub mod gem;
 pub mod governing_locks;
-pub mod gem;
 pub(crate) mod maven;
 pub(crate) mod nuget;
 pub mod pnpm;

diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs
--- a/crates/socket-patch-core/src/hosted/memory/mod.rs
+++ b/crates/socket-patch-core/src/hosted/memory/mod.rs
@@ -66,9 +66,9 @@
     classify, lookup_incomplete, mentioned_uuids, offers_from_results, Offers, RecordedIndex, Row,
     Stage, ROLLOUT_DEFERRED,
 };
+use crate::utils::purl_key::PurlKey;
 use discover::Provider;
 use stages::{Planned, RewriteRefused, Rewritten, StageOptions};
-use crate::utils::purl_key::PurlKey;
 
 /// `"<crate version>+<git sha or 'unknown'>"`; the sha comes from the
 /// `SOCKET_PATCH_GIT_SHA` build-time variable.

diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs
--- a/crates/socket-patch-core/src/patch/redirect/mod.rs
+++ b/crates/socket-patch-core/src/patch/redirect/mod.rs
@@ -77,9 +77,9 @@
 use crate::formats::yarn::source::{classic_copy_source, CopySource};
 use crate::formats::yarn::stanzas::{stanza_key, BerryStanzas};
 #[cfg(test)]
+mod platform_wheel_tests;
+#[cfg(test)]
 mod pnpm_equivalence_tests;
-#[cfg(test)]
-mod platform_wheel_tests;
 mod poetry;
 mod pypi_takeover;
 pub use pypi_takeover::preflight_pypi_takeover;
@@ -565,7 +565,7 @@
         bun_lockb_present,
         &std::collections::BTreeSet::new(),
         &std::collections::BTreeSet::new(),
-     &yarnrc::OuterYarnMirror::default(),
+        &yarnrc::OuterYarnMirror::default(),
     )
 }
 
@@ -6760,8 +6760,10 @@
     // One pass over the pom's repositories: `(id, url)` of each, which also
     // answers the per-dep URL-refresh check below while the pom is still
     // unchanged (a no-op rescan then never re-scans the pom per dep).
-    let original_repos: Vec<(String, Option<String>)> =
-        pom.as_deref().map(maven_repository_ids_and_urls).unwrap_or_default();
+    let original_repos: Vec<(String, Option<String>)> = pom
+        .as_deref()
+        .map(maven_repository_ids_and_urls)
+        .unwrap_or_default();
     let hosted_repo_generations: std::collections::BTreeSet<String> = original_repos
         .iter()
         .filter_map(|(id, _)| generation::pin_name_uuid(id, false).map(str::to_string))
@@ -10833,7 +10835,10 @@
             &[(YARNRC_REL, "yarn-offline-mirror: false\n")],
             &[(YARNRC_REL, "yarn-offline-mirror:\n")],
             &[(YARNRC_REL, "yarn-offline-mirror \"\"\n")],
-            &[(YARNRC_REL, "yarn-offline-mirror-pruning true\n# yarn-offline-mirror ./m\n")],
+            &[(
+                YARNRC_REL,
+                "yarn-offline-mirror-pruning true\n# yarn-offline-mirror ./m\n",
+            )],
             &[(npmrc::NPMRC_REL, "[scope]\nyarn-offline-mirror=./m\n")],
             &[
                 (YARNRC_REL, "yarn-offline-mirror false\n"),
@@ -10849,7 +10854,10 @@
             let mut r = RewriteResult::default();
             rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r);
             assert!(r.warnings.is_empty(), "{rcs:?}: {:?}", r.warnings);
-            assert!(r.files["yarn.lock"].contains("http://p.test/lp.tgz"), "{rcs:?}");
+            assert!(
+                r.files["yarn.lock"].contains("http://p.test/lp.tgz"),
+                "{rcs:?}"
+            );
             assert!(r.refused_yarn_classic_uuids.is_empty(), "{rcs:?}");
         }
     }
@@ -10874,7 +10882,10 @@
         rewrite_yarn_classic(&files, std::slice::from_ref(&other), &mut r);
         assert!(r.refused_yarn_classic_uuids.is_empty());
         assert_eq!(
-            r.warnings.iter().map(|w| w.code.as_str()).collect::<Vec<_>>(),
+            r.warnings
+                .iter()
+                .map(|w| w.code.as_str())
+                .collect::<Vec<_>>(),
             ["redirect_yarn_classic_entry_not_found"]
         );
     }

diff --git a/crates/socket-patch-core/src/patch/redirect/poetry.rs b/crates/socket-patch-core/src/patch/redirect/poetry.rs
--- a/crates/socket-patch-core/src/patch/redirect/poetry.rs
+++ b/crates/socket-patch-core/src/patch/redirect/poetry.rs
@@ -89,7 +89,9 @@
                             new: Some(Value::String(new)),
                         });
                     }
-                    result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
+                    result
+                        .confirmed_python_lock_uuids
+                        .insert(dep.patch_uuid.clone());
                     if !stale_warned {
                         if let Some(format) =
                             *writer_format.get_or_insert_with(|| pre_1_4_writer(&content))
@@ -124,14 +126,18 @@
                 }
                 // Already redirected to this artifact (idempotent re-scan).
                 LockStep::Unchanged => {
-                    result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
+                    result
+                        .confirmed_python_lock_uuids
+                        .insert(dep.patch_uuid.clone());
                 }
                 LockStep::NotFound => result.warnings.push(RewriteWarning {
                     code: "redirect_poetry_entry_not_found".into(),
                     detail: format!("no {path} entry for {}@{}", dep.name, dep.version),
                 }),
                 LockStep::Refused(detail) => {
-                    result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
+                    result
+                        .refused_python_lock_uuids
+                        .insert(dep.patch_uuid.clone());
                     result.warnings.push(RewriteWarning {
                         code: "redirect_poetry_lock_unsupported".into(),
                         detail: format!("{path}: {detail}"),

diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs
--- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs
+++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs
@@ -489,6 +489,9 @@
         let other = format!("log = {{ version = \"0.4.20\", registry = \"socket-patch-{C}\" }}\n");
         let (outcome, after) = restore_b(&manifest(&other), &config).await;
         assert_eq!(outcome.restored().count(), 1, "{:?}", outcome.pins);
-        assert_eq!(after.as_deref().map(str::trim_start), Some(block(C).as_str()));
+        assert_eq!(
+            after.as_deref().map(str::trim_start),
+            Some(block(C).as_str())
+        );
     }
 }

diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs
--- a/crates/socket-patch-core/src/utils/group_commit.rs
+++ b/crates/socket-patch-core/src/utils/group_commit.rs
@@ -1335,7 +1335,10 @@
             (".socket/vendor/.gitattributes", true),
             (".socket/vendor/gradle/g/a/maven-metadata.xml", true),
             (".socket/vendor/gradle/g/a/1/a-1.jar", false),
-            (".socket/vendor/gradle/g/a/1/socket-patch.vendor.json", false),
+            (
+                ".socket/vendor/gradle/g/a/1/socket-patch.vendor.json",
+                false,
+            ),
             (".socket/vendor/npm/u/left-pad-1.3.0.tgz", false),
             (".socket/manifest.json", false),
             ("packages/a/.socket/vendor/npm/u/a.tgz", false),
@@ -1437,10 +1440,16 @@
             if keep {
                 group.rollback_to(savepoint);
                 group.commit().await.unwrap();
-                assert!(unit.join("a.tgz").exists(), "a rolled-back removal is forgotten");
+                assert!(
+                    unit.join("a.tgz").exists(),
+                    "a rolled-back removal is forgotten"
+                );
             } else {
                 drop(group);
-                assert!(unit.join("a.tgz").exists(), "a dropped group deletes nothing");
+                assert!(
+                    unit.join("a.tgz").exists(),
+                    "a dropped group deletes nothing"
+                );
             }
         }
 
@@ -1449,8 +1458,14 @@
         remove_tree_and_prune(&unit, &socket).await.unwrap();
         group.commit().await.unwrap();
         assert!(!unit.exists());
-        assert!(!socket.join("vendor").exists(), "the emptied levels are pruned");
-        assert!(socket.join("apply.lock").exists(), "`.socket/` itself stays");
+        assert!(
+            !socket.join("vendor").exists(),
+            "the emptied levels are pruned"
+        );
+        assert!(
+            socket.join("apply.lock").exists(),
+            "`.socket/` itself stays"
+        );
     }
 
     /// A journal the commit had to create `.socket/vendor/` for (a hosted

diff --git a/crates/socket-patch-core/src/utils/target.rs b/crates/socket-patch-core/src/utils/target.rs
--- a/crates/socket-patch-core/src/utils/target.rs
+++ b/crates/socket-patch-core/src/utils/target.rs
@@ -163,13 +163,36 @@
             .collect();
         packages.sort();
         packages.dedup();
-        (packages.len() > 1).then(|| {
-            format!(
-                "\"{}\" is ambiguous: it names {}; use the full name or a purl",
-                self.text,
-                packages.join(", ")
-            )
-        })
+        if packages.len() <= 1 {
+            return None;
+        }
+        // If the typed name exactly matches one package's full name, it's
+        // not ambiguous: prefer the exact match over last-segment hits.
+        let typed = self.text.to_lowercase();
+        let exact_matches = packages
+            .iter()
+            .filter(|pkg_id| {
+                let Some(rest) = pkg_id.strip_prefix("pkg:") else {
+                    return false;
+                };
+                let Some((eco, name)) = rest.split_once('/') else {
+                    return false;
+                };
+                if eco == "pypi" {
+                    canonicalize_pypi_name(&typed) == name
+                } else {
+                    typed == name
+                }
+            })
+            .count();
+        if exact_matches == 1 {
+            return None;
+        }
+        Some(format!(
+            "\"{}\" is ambiguous: it names {}; use the full name or a purl",
+            self.text,
+            packages.join(", ")
+        ))
     }
 
     /// Does this target select the recorded patch `(purl, uuid)` — a
@@ -474,6 +497,18 @@
         assert!(Target::parse("six")
             .ambiguity(["pkg:pypi/six@1", "pkg:npm/six@1"])
             .is_some());
+        // Exact unscoped name is not ambiguous even when a scoped variant
+        // also matches by last segment (lodash vs @types/lodash).
+        assert_eq!(
+            Target::parse("lodash")
+                .ambiguity(["pkg:npm/lodash@4.17.21", "pkg:npm/@types/lodash@4.14.0",]),
+            None
+        );
+        assert_eq!(
+            Target::parse("react")
+                .ambiguity(["pkg:npm/react@18.0.0", "pkg:npm/@types/react@18.0.0",]),
+            None
+        );
     }
 
     #[test]

diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs
--- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs
+++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs
@@ -999,7 +999,10 @@
         .filter(|l| !l.starts_with('#'))
         .collect::<Vec<_>>()
         .join("\n");
-    assert!(!headerless.contains("lockfile v1"), "fixture drops the header");
+    assert!(
+        !headerless.contains("lockfile v1"),
+        "fixture drops the header"
+    );
     write(tmp.path(), "yarn.lock", &headerless).await;
     let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap();
     assert_eq!(flavor, NpmLockFlavor::YarnClassic);

diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs
--- a/crates/socket-patch-core/src/vendor/mod.rs
+++ b/crates/socket-patch-core/src/vendor/mod.rs
@@ -129,8 +129,8 @@
 };
 // The hosted→vendored takeover refuses a berry project the backend would
 // refuse BEFORE it reverts the hosted redirect.
+pub use npm_common::npm_tarball_gitignore_preflight;
 pub use npm_lock::npm_lock_vendor_preflight;
-pub use npm_common::npm_tarball_gitignore_preflight;
 pub use yarn_berry_lock::{yarn_berry_vendor_preflight, yarn_berry_vendor_target_preflight};
 
 use std::collections::{HashMap, HashSet};

diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs
--- a/crates/socket-patch-core/src/vendor/state.rs
+++ b/crates/socket-patch-core/src/vendor/state.rs
@@ -1124,9 +1124,11 @@
         let other = "pkg:npm/core@1.0.0";
         let core_name = Target::parse("core");
         assert!(core.matches_target(core_key, &core_name));
+        // Not ambiguous: "core" is the exact full name of the npm package,
+        // even though it's also the last segment of the golang package.
         assert!(core_name
             .ambiguity([core.ambiguity_purl(core_key, &core_name), other])
-            .is_some());
+            .is_none());
         // One entry, encoded key plus decoded base: one package.
         let sushi = Target::parse("toml");
         assert_eq!(sushi.ambiguity([entry.ambiguity_purl(key, &sushi)]), None);

diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
--- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
+++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
@@ -46,9 +46,7 @@
 use crate::constants::SOCKET_DIR;
 use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin};
 use crate::formats::yarn::berry_gates::{self, BerryGate, Yarnrc, SUPPORTED_CACHE_KEY};
-use crate::formats::yarn::blocks::{
-    berry_field, block_eol, replace_block, scan_blocks, LockBlock,
-};
+use crate::formats::yarn::blocks::{berry_field, block_eol, replace_block, scan_blocks, LockBlock};
 use crate::formats::yarn::patterns::{pattern_real_name, split_berry_key_patterns, split_pattern};
 use crate::manifest::schema::PatchRecord;
 use crate::patch::apply::{normalize_file_path, PatchSources};

diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs
--- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs
+++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs
@@ -1512,11 +1512,7 @@
             .filter(|&c| c != "vendor_prebuilt_downloaded")
             .collect();
         assert_eq!(codes, ["vendor_yarn_classic_non_registry_legacy_wiring"]);
-        let detail = &warnings
-            .iter()
-            .find(|w| w.code == codes[0])
-            .unwrap()
-            .detail;
+        let detail = &warnings.iter().find(|w| w.code == codes[0]).unwrap().detail;
         assert!(
             detail.contains("host.test/fork")
                 && detail.contains("vendor --revert")

diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs
--- a/crates/socket-patch-core/src/vex/discover/yarn.rs
+++ b/crates/socket-patch-core/src/vex/discover/yarn.rs
@@ -90,12 +90,12 @@
     DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE,
 };
 use crate::formats::yarn::blocks::{berry_field, classic_field};
+use crate::formats::yarn::is_berry_lock;
 use crate::formats::yarn::patterns::{
     classic_key_real_name, pattern_real_name, resolution_selector_target, split_resolved_sha1,
     BerryLocator,
 };
 use crate::formats::yarn::source::{classic_copy_source, CopySource};
-use crate::formats::yarn::is_berry_lock;
 use crate::utils::digest::is_sri_pin;
 use crate::vendor::lock_inventory::yarn::{
     berry_checksum_pin, berry_entries, classic_entries, BerryLock, YarnEntry,

diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs
--- a/crates/socket-patch-core/src/vex/product.rs
+++ b/crates/socket-patch-core/src/vex/product.rs
@@ -1425,7 +1425,9 @@
     async fn detect_git_remote_handles_non_existent_start_path() {
         let dir = tempfile::tempdir().unwrap();
         let nonexistent = dir.path().join("does/not/exist");
-        assert!(detect_git_remote(&nonexistent, &mut Vec::new()).await.is_none());
+        assert!(detect_git_remote(&nonexistent, &mut Vec::new())
+            .await
+            .is_none());
     }
 
     /// B22: inside a submodule (`.git` is a `gitdir:` FILE), the product is

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

Comment thread crates/socket-patch-core/src/utils/target.rs
# Conflicts:
#	crates/socket-patch-cli/CLI_CONTRACT.md
#	crates/socket-patch-cli/src/commands/get.rs

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

# Conflicts:
#	crates/socket-patch-cli/CLI_CONTRACT.md

Co-Authored-By: Claude <noreply@anthropic.com>
@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.

ambiguity() counted every package a name reaches, by full name or by last
segment, so 'get lodash' in any project that also installs @types/lodash
refused with 'use the full name or a purl' although the full name was
typed. When some matches are by full name, only those are counted now;
two last-segment-only matches (core -> @angular/core, @babel/core) and
the same full name in two ecosystems stay ambiguous.

Co-Authored-By: Claude <noreply@anthropic.com>
@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.

8e62ca9 lets a full-name match settle an ambiguous name, so `core` beside
`pkg:npm/core` now names that package alone and the vendor-state test's
ambiguity assertion failed (test windows-1, coverage). The test is about
the golang entry being counted under its decoded base purl, so pair it
with another last-segment-only match (`@x/core`) instead: the assertion
still fails if the golang entry stops being counted.

Co-Authored-By: Claude <noreply@anthropic.com>
@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.

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

Copy link
Copy Markdown
Collaborator Author

[burn-down] Ready for review at 703694e88. CI 461/461 green (447 success, 14 skipped, incl. ci-ok); mergeable, no conflicts. Bugbot reviewed 703694e with no new issues. The last open thread (ambiguity refusing lodash beside @types/lodash) was fixed in 8e62ca9; I checked Target::ambiguity now keeps full-name matches first, and resolved the thread. Reviewer decision left open, per that reply: selection (matches_package / matches_patch) still reaches @types/lodash by last segment, as before this PR.


Generated by Claude Code

8e62ca9 let a full-name match settle an ambiguous name (`lodash` beside
`@types/lodash` names `lodash`), but only in the refusal. Selection still
matched by last segment, so `remove lodash` and `rollback lodash` also
removed and rolled back the `@types/lodash` patch, and `get lodash`
searched and acted on every installed `@types/lodash` version.

Target::settle now returns the target to act on: Err for an ambiguous
name, otherwise the name narrowed to its full-name match when that match
won. get, remove and rollback act on the settled target, so they never
select more than the check allowed. ambiguity() stays as a wrapper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 9, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[final reviewer] I found a selection bug in final review, fixed it in ca475de4 and removed Ready for review. The burn-down re-labels once CI is green.

The bug. 8e62ca9 lets a full-name match settle an ambiguous name, but only inside Target::ambiguity. Selection still matched by last segment (package_spec_matches → name.rsplit('/')). With records for both pkg:npm/lodash and pkg:npm/@types/lodash:

  • remove lodash passed the ambiguity check, then matching / remove_matching / vendor_entries_matching / hosted_pins_matching selected both. That rolled back and removed the user's @types/lodash patch and its wiring too.
  • rollback lodash did the same through ledgers.matching(id).
  • get lodash kept every installed @types/lodash version in matched, then searched and acted on them.

The fix. Target::settle(purls) -> Result<Target, String> returns the target to act on. It returns Err for an ambiguous name. Otherwise it returns the name narrowed to its full-name match when that match won. get, remove and rollback now act on the settled target. In remove, matching is computed after the check. In rollback, each id's stores are re-selected with the settled id. ambiguity() stays as a thin wrapper. The verbatim-uuid match for a name is kept. A unit test in target.rs asserts that the settled lodash no longer selects @types/lodash, and that it does when there is no competing full-name match.

Ran locally: cargo clippy --locked --workspace --all-features -- -D warnings (CI's command) is clean. cargo test -p socket-patch-core --lib utils::target passes (11), and so does cargo test -p socket-patch-cli --lib commands:: (678).

bugbot run


Generated by Claude Code

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

✅ 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 ca475de. Configure here.

@mikolalysenko
Mikola Lysenko (mikolalysenko) added this pull request to the merge queue Oct 9, 2026
Any commits made after this event will not be merged.
@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 9, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: labeled Ready for review at ca475de.

  • CI: 447/462 check runs succeeded on this head, 15 skipped, 0 failing. The gradle 8.14.3 / jdk 21 / vendor / real-central leg failed on a Maven Central HTTP 429 while downloading Maven (registry rate limit, not this PR); its one re-run passed.
  • Bugbot: reviewed ca475de with no findings; no unresolved review threads.
  • No merge conflict with current main (c52bb9b); no CHANGELOG.md changes.

Generated by Claude Code

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 9, 2026
# Conflicts:
#	crates/socket-patch-cli/src/commands/get.rs
#	crates/socket-patch-cli/src/commands/remove.rs
#	crates/socket-patch-cli/src/commands/rollback.rs
@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 9, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Merged main (e03a666d) at 12af0a24 to clear conflicts with #1041 (org resolution / TelemetryAuth), #1007 and #1008 in get.rs, remove.rs, rollback.rs. Removed Ready for review until CI is green on the new head.

  • Telemetry: main's &telemetry (TelemetryAuth) everywhere this PR still passed token/org (incl. the new ambiguous_target branch in rollback and the concurrent search's report_fetch_failure).
  • get <uuid>: this PR's policy_bypassed warnings are still printed, then main's org_warnings are appended (JSON-only, as on main's search path) and the combined list goes to agent/hosted/vendored.
  • Kept this PR's per-version concurrent search and Target grammar; dropped is_purl/patch_matches imports it replaced.
  • Local: cargo check -p socket-patch-cli --all-targets clean; --lib 912 passed; 23 get/remove/rollback/target suites all pass (e.g. covgap_commands_get 92, remove 99, rollback 57, in_process_target_ambiguity 7).

Generated by Claude Code

Comment thread crates/socket-patch-cli/src/commands/get.rs Dismissed
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: this PR is green except CodeQL, which flags one new high alert: #204 rust/cleartext-logging at crates/socket-patch-cli/src/commands/get.rs:1226 ("writes uuid_warnings to a log file").

I read it as a false positive. uuid_warnings comes from scan::policy::policy_bypass_warnings, which formats "{purl} would be skipped by socket.yml ({code}: {detail}); get patches it anyway". That's a normalized purl plus a policy reason, never a token. Main prints the same warnings in the search path of get.rs, and alerts with the same pattern (#171, #193) were dismissed as false positives.

Question for a human: can you dismiss alert #204 as a false positive (or tell me if you want the warning printing changed)? Agents aren't allowed to dismiss security alerts. Everything else on 12af0a24 is green; after the dismissal, the next burn-down run will request Bugbot on this merge head and label the PR.


Generated by Claude Code

@mikolalysenko
Mikola Lysenko (mikolalysenko) added this pull request to the merge queue Oct 9, 2026
Any commits made after this event will not be merged.

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

agent:needs-human 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)

4 participants