Skip to content

Fix Python env discovery ignoring uv/PDM env (#502, #525, #528) - #540

Merged
Mikola Lysenko (mikolalysenko) merged 14 commits into
mainfrom
agent/fix-python-manager-project-env
Oct 2, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 14 commits into
mainfrom
agent/fix-python-manager-project-env

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #502
Fixes #525
Fixes #528

Summary

For the Python project env, socket-patch now uses the env that uv or PDM actually installs into: a PDM venv, a PEP 582 __pypackages__ dir, or a uv UV_PROJECT_ENVIRONMENT. It no longer guesses ./.venv or falls back to the PATH interpreter. Agent mode patches that env. The hosted stale-install warning and vex check it, so vex no longer reports not_affected for an install that is still unpatched.

Root cause

find_local_venv_site_packages_with (crates/socket-patch-core/src/crawlers/python_crawler.rs) picks the project env from VIRTUAL_ENV, Pipenv, Poetry, ./.venv and ./venv. It never asks the project's own package manager where it installed:

So discovery fell through to a stray ./.venv, or to the PATH interpreter through the is_python_project fallback. All three consumers read this one function: agent apply, the hosted redirect_pypi_stale_install probe (scan/hosted/python.rs), and vex. All three issues are fixed in this one place.

Fix

A new step 0, package_manager_recorded_site_packages, runs ahead of the generic probes, and a PEP 582 step 5 runs after them:

  • PDM applies only to a PDM project (.pdm-python, pdm.lock, .pdm.toml or [tool.pdm]) that has no uv.lock or poetry.lock, since those drive installs ahead of pdm.lock, as the hosted rewriters already assume. If there's an interpreter to follow (PDM_PYTHON, else the saved one unless PDM_IGNORE_SAVED_PYTHON), step 0 uses its venv's site-packages, or __pypackages__/*/lib for a base interpreter. With no interpreter, PDM picks an activated or in-project venv first, so the generic probes decide. __pypackages__ is the last resort (step 5) when they find nothing.
  • uv: uses UV_PROJECT_ENVIRONMENT (absolute, or relative to the project) only for a project uv drives, so the ambient variable can't hijack another manager's project. That means a project with uv.lock, or a pyproject.toml with no [tool.poetry] or PDM settings ([tool.pdm] beyond build, .pdm.toml) and no poetry.lock, poetry.toml, pdm.lock, .pdm-python or Pipfile.
  • When the recorded env exists, it wins and a stray ./.venv is ignored. When it doesn't exist yet (not synced), the existing probes and fallback decide, so behavior there is unchanged.
  • Every file read goes through read_regular_to_string, so a FIFO in place of pyproject.toml can't wedge the scan.

Docs: CLI_CONTRACT probe list, the PDM page (replaces the "__pypackages__ not covered" limit), and a uv UV_PROJECT_ENVIRONMENT note.

Out of scope, unchanged: when no project env exists at all, the is_python_project → global-interpreter fallback still applies. That's the open maintainer question in #504.

Per-issue test checklist (red on main → green here)

To show red, I disabled step 0. All 3 core tests and all 4 CLI tests then fail. On main, the hosted PDM test reproduces the false attestation (exit 0, "warnings":[], vex.statements: 1 over upstream bytes).

Review rounds

  • Bugbot on ab7aeb6 found two issues, both real and both fixed in 60dfb81 with tests: PDM_PYTHON was ignored, and a leftover PDM record could shadow a uv.lock project. Bugbot's re-review of 60dfb81 found no new issues.
  • Review on 60dfb81 found two more issues, both fixed in 5ecac79 with tests: a lockless Poetry project ([tool.poetry] / poetry.toml) could be taken over by an ambient UV_PROJECT_ENVIRONMENT, and with no saved PDM interpreter __pypackages__ beat an activated or in-project venv. PEP 582 is now the last resort there, as in PDM.
  • Re-review of 5ecac79 found one regression, fixed in 2a22d4e with tests: the uv probe now keeps lockless PDM projects ([tool.pdm], .pdm.toml) out of UV_PROJECT_ENVIRONMENT, and uv.lock still wins. A [tool.pdm.build]-only table (the pdm-backend build backend) doesn't count as PDM-managed.
  • Bugbot on 5ecac79 flagged the same lockless-PDM case (already fixed in 2a22d4e), plus PDM_IGNORE_ACTIVE_VENV being ignored. That second one is fixed in c1710a9 with a test: for a PDM project, the VIRTUAL_ENV probe now honors the opt-out.
  • Re-review of c1710a9: PDM_IGNORE_ACTIVE_VENV=0 and other false values were read as true. Fixed in aabaf5b: PDM's boolean env settings are now parsed like PDM's ensure_boolean, with tests for the false values.
  • Bugbot on aabaf5b: a base PDM_PYTHON displaced the saved venv and meant PEP 582 even with PDM's default use_venv = true, and conda envs weren't recognized. Fixed in ca62351: the first recorded interpreter that is an env wins, conda envs (conda-meta/) count as envs, and a base interpreter means PEP 582 only when python.use_venv is off. Bugbot's autofix (8c9d28d) for the same finding was merged in 2b5c881, keeping ca62351's logic.

Verification

  • CI on 60dfb81: all 340 check runs green (334 success, 6 skipped matrix/conditional jobs). c1710a9 conflicted with main once Fix Poetry venv selection ignoring envs.toml (#476, #526) #527 (Poetry env selection) landed. db9160b merges main, keeping its poetry_active_prefix with the PDM_IGNORE_ACTIVE_VENV gate added on top. CI on 2b5c881: all 340 check runs green (334 success, 6 skipped).
  • On db9160b: core lib 4725 passed (only the 4 root-only failures that also fail on main here), plus in_process_python_envs 17/17, in_process_redirect_pdm 5/5, in_process_redirect_poetry 7/7, in_process_redirect_pipenv 6/6, in_process_pypi_apply 5/5, in_process_pypi_multi_release 4/4 and scan 104/104; clippy clean. Earlier, on c1710a9: core python_crawler 57/57, in_process_python_envs 17/17, in_process_redirect_pdm 5/5, scan 104/104; cargo clippy --workspace --all-features -- -D warnings clean.
  • Earlier full run, cargo test --workspace --all-features --no-fail-fast: 9474 passed. The 13 failures, besides one FIFO case, are all write-failure or unremovable-file tests that can't fail when running as root (uid 0) in this container. I confirmed the core ones fail identically on unmodified main in this container. The FIFO case (scan::hosted_symlinked_files::hosted_scan_returns_with_fifo_candidate) was a real regression in an earlier commit, also caught by CI coverage. ab7aeb6 fixes it.
  • cargo fmt --all -- --check isn't clean on main with this toolchain either, and CI doesn't run it, so I kept fmt changes to my own hunks.
  • npm/pypi/gem wrappers: no change needed (they only dispatch the binary).

🤖 Generated with Claude Code


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
socket-patch picked a Python project's environment from VIRTUAL_ENV,
Pipenv, Poetry, ./.venv and ./venv. It never asked uv or PDM where they
installed the project. With UV_PROJECT_ENVIRONMENT set, a PDM
.pdm-python pointing at an out-of-tree venv, or a PEP 582
__pypackages__ layout, agent mode patched a stray .venv or the PATH
Python. It also skipped the package as not installed, the hosted
stale-install warning stayed silent, and vex attested installs that
were still unpatched.

Discovery now checks the env the project's manager records before
the generic probes: PDM's saved interpreter (its venv, or
__pypackages__/<X.Y>/lib for a base interpreter or PDM 1.x), then
UV_PROJECT_ENVIRONMENT for a project uv drives.

Fixes #502, #525, #528.

Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-opus-5-5
PDM disregards the saved .pdm-python interpreter when
PDM_IGNORE_SAVED_PYTHON is set, so discovery does too and falls back
to the generic probes.

Assisted-by: Claude Code:claude-opus-5-5
The new PDM env probe opened pyproject.toml, .pdm-python and
.pdm.toml with a plain read, so a FIFO in their place wedged scan
forever (caught by hosted_scan_returns_with_fifo_candidate). Read them
with read_regular_to_string, as the Poetry probe already does.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 2, 2026 08:18
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


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 2 potential issues.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: PDM env shadows uv projects
    • Added priority check in pdm_project_site_packages to return None when uv.lock or poetry.lock exists, ensuring uv and Poetry environments take precedence over PDM's saved interpreter.

Create PR

Or push these changes by commenting:

@cursor push 810f39b188
Preview (810f39b188)
diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs
--- a/crates/socket-patch-cli/src/commands/apply.rs
+++ b/crates/socket-patch-cli/src/commands/apply.rs
@@ -2,9 +2,7 @@
 use socket_patch_core::api::blob_fetcher::get_missing_blobs;
 use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient};
 use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning;
-use socket_patch_core::crawlers::{
-    detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler,
-};
+use socket_patch_core::crawlers::{detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler};
 use socket_patch_core::manifest::operations::read_manifest;
 use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
 use socket_patch_core::patch::apply::{

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;
@@ -135,9 +135,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
@@ -147,10 +149,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(),
@@ -158,7 +158,8 @@
                 description: String::new(),
                 license: String::new(),
                 tier: String::new(),
-            });
+            }
+        });
     }
     state
 }
@@ -185,4 +186,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
@@ -17,9 +17,9 @@
     pin_before_hash_blobs, rollback_patches_inner, run_hosted_leg, sweep_failure,
     sweep_unused_artifacts, HostedLegOutcome, InnerSelection,
 };
-use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend};
 use crate::args::{apply_env_toggles, GlobalArgs};
 use crate::commands::lock_cli::acquire_or_emit;
+use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend};
 use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status};
 use crate::ui::plural;
 

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
@@ -10,13 +10,13 @@
 };
 use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
 use socket_patch_core::patch::apply::select_installed_variants;
+use socket_patch_core::patch::redirect::upstream::HostedPin;
 use socket_patch_core::patch::rollback::{
     cannot_rollback_error, rollback_package_patch, verify_file_rollback, RollbackResult,
     VerifyRollbackResult, VerifyRollbackStatus,
 };
 use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back};
 use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers};
-use socket_patch_core::patch::redirect::upstream::HostedPin;
 use socket_patch_core::vendor::{purl_keys_cover, RevertOpts, VendorState};
 use std::collections::{HashMap, HashSet};
 use std::path::{Path, PathBuf};
@@ -1026,7 +1026,8 @@
             .iter()
             .map(|(code, detail)| (code.to_string(), detail.clone())),
     );
-    out.edited_files.extend(outcome.reverted_files.iter().cloned());
+    out.edited_files
+        .extend(outcome.reverted_files.iter().cloned());
     let unwound: Vec<_> = vlt_targets
         .into_iter()
         .filter(|t| out.reverted.iter().any(|p| p == &t.purl))
@@ -1170,7 +1171,11 @@
             } else if !args.common.silent {
                 println!(
                     "{} the pre-v5 hosted ledger {}: no lockfile pins a hosted patch.",
-                    if args.common.dry_run { "Would remove" } else { "Removed" },
+                    if args.common.dry_run {
+                        "Would remove"
+                    } else {
+                        "Removed"
+                    },
                     socket_patch_core::patch::redirect::REDIRECT_STATE_REL
                 );
             }

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
@@ -934,7 +934,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,
@@ -946,6 +947,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.
@@ -2171,13 +2173,19 @@
 /// artifacts, then verify with `vex`. After a vendored→hosted takeover
 /// (`vendored_removed`) the commit also has to carry the deleted vendored
 /// ledger entries and artifacts.
-fn format_next_steps(files: &[String], edits: &[socket_patch_core::patch::redirect::FileEdit], vendored_removed: bool) -> Vec<String> {
+fn format_next_steps(
+    files: &[String],
+    edits: &[socket_patch_core::patch::redirect::FileEdit],
+    vendored_removed: bool,
+) -> Vec<String> {
     if files.is_empty() && !vendored_removed {
         return Vec::new();
     }
     let mut commit: Vec<String> = Vec::new();
     if vendored_removed {
-        commit.push(".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string());
+        commit.push(
+            ".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string(),
+        );
     }
     commit.extend(files.iter().cloned());
     let npm = files
@@ -4162,19 +4170,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/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs
--- a/crates/socket-patch-cli/src/commands/scan/mod.rs
+++ b/crates/socket-patch-cli/src/commands/scan/mod.rs
@@ -35,17 +35,17 @@
 
 use super::get::{download_and_apply_patches_with, DownloadParams, DownloadRun};
 
+use self::policy::{load_invocation_policy, InvocationPolicy, PolicyLoadError, ScanPolicy};
 pub use self::socket_yml_args::{SocketYmlArgs, MIN_SEVERITY_ENV};
-use self::policy::{load_invocation_policy, InvocationPolicy, PolicyLoadError, ScanPolicy};
 
 mod discovery;
 mod gc;
 pub(crate) mod hosted;
 pub(crate) mod policy;
-mod socket_yml_args;
 pub(crate) mod render;
 pub(crate) mod rollout;
 pub mod rollout_args;
+mod socket_yml_args;
 pub(crate) mod vendor_flow;
 
 use self::discovery::{
@@ -65,13 +65,13 @@
 pub(crate) use self::hosted::boxed_run_redirect_selected;
 use self::hosted::run_redirect;
 pub(crate) use self::hosted::{vlt_rollback_heal, vlt_takeover_heal};
-pub(crate) use self::vendor_flow::{
-    boxed_vendor_step, preview_vendor_json, print_dry_run_refusals, VendorStep,
-};
 use self::vendor_flow::{
     boxed_vendor_interactive_path, boxed_vendor_json_path, fold_vendored_skips_into_apply,
     partition_skipped_selected,
 };
+pub(crate) use self::vendor_flow::{
+    boxed_vendor_step, preview_vendor_json, print_dry_run_refusals, VendorStep,
+};
 
 /// Packages per batch request on the authenticated API when `--batch-size`
 /// is not given: the server's own per-request maximum
@@ -318,11 +318,7 @@
     /// `requests`), or a purl with or without its version
     /// (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`). Repeat the flag or
     /// separate with commas
-    #[arg(
-        long = "package",
-        env = "SOCKET_SCAN_PACKAGES",
-        value_delimiter = ','
-    )]
+    #[arg(long = "package", env = "SOCKET_SCAN_PACKAGES", value_delimiter = ',')]
     pub packages: Vec<String>,
 
     /// On a successful scan, also generate an OpenVEX 0.2.0 document.
@@ -500,9 +496,10 @@
     telemetry.flush().await;
     let error_count = failures.len();
     if error_count > 0 && error_count == packages.len() {
-        let err = failures
-            .last()
-            .map_or_else(|| "all patch-detail queries failed".to_string(), |(_, e)| e.clone());
+        let err = failures.last().map_or_else(
+            || "all patch-detail queries failed".to_string(),
+            |(_, e)| e.clone(),
+        );
         let message = format!("all {error_count} patch-detail queries failed: {err}");
         if detail_error_line {
             eprintln!("{}", render::fetch_details_failed(&failures));
@@ -568,7 +565,11 @@
     packages: &[BatchPackagePatches],
     result: Option<&mut serde_json::Value>,
 ) -> Vec<rollout::Row> {
-    let failed: Vec<String> = discovered.failed.iter().map(|(purl, _)| purl.clone()).collect();
+    let failed: Vec<String> = discovered
+        .failed
+        .iter()
+        .map(|(purl, _)| purl.clone())
+        .collect();
     stage.incomplete = rollout::lookup_incomplete(&recorded.index, &failed, batch_failed);
     let rows = rollout::classify(&discovered.offers, &recorded.index, &stage.project);
     if let Some(result) = result {
@@ -1317,7 +1318,8 @@
         let joined = cwd.join(raw);
         if raw.contains(['*', '?', '[']) {
             let pattern = joined.to_string_lossy().into_owned();
-            let matches = glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?;
+            let matches =
+                glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?;
             let before = dirs.len();
             dirs.extend(
                 matches
@@ -1390,7 +1392,10 @@
     }
     // One budget per invocation (§5.2): the directories spend it in sorted
     // order, and a package admitted in one is admitted free in the next.
-    let configured = match args.rollout.resolve_from_env(invocation.policy.max_new_patches()) {
+    let configured = match args
+        .rollout
+        .resolve_from_env(invocation.policy.max_new_patches())
+    {
         Ok(max) => max,
         Err(message) => {
             eprintln!("Error: {message}");
@@ -1491,7 +1496,10 @@
     // error.
     let configured_cap = match args.rollout.carry.as_ref() {
         Some(carry) => carry.lock().configured,
-        None => match args.rollout.resolve_from_env(invocation.policy.max_new_patches()) {
+        None => match args
+            .rollout
+            .resolve_from_env(invocation.policy.max_new_patches())
+        {
             Ok(max) => max,
             Err(message) => {
                 eprintln!("Error: {message}");
@@ -1499,11 +1507,8 @@
             }
         },
     };
-    let mut stage = rollout::Stage::new(
-        configured_cap,
-        args.rollout.carry.clone(),
-        &args.common.cwd,
-    );
+    let mut stage =
+        rollout::Stage::new(configured_cap, args.rollout.carry.clone(), &args.common.cwd);
 
     // Strict airgap (CLI_CONTRACT.md `--offline`): scan's patch discovery
     // is remote data, so refuse before the crawl and before the API client
@@ -1689,8 +1694,11 @@
         .filter(|pkg| args.common.purl_ecosystem_selected(&pkg.purl))
         .collect();
 
-    let package_specs: Vec<&String> =
-        args.packages.iter().filter(|s| !s.trim().is_empty()).collect();
+    let package_specs: Vec<&String> = args
+        .packages
+        .iter()
+        .filter(|s| !s.trim().is_empty())
+        .collect();
     let filtered_crawled: Vec<_> = if package_specs.is_empty() {
         filtered_crawled
     } else {
@@ -1828,13 +1836,12 @@
                 // `redirectState` rides the empty-discovery envelope too
                 // (same rule as the ≥1-package path). `wiringLive` is empty
                 // by construction: this run covered zero packages.
-                let redirect_state = (!args.common.is_global()).then_some(
-                    crate::commands::hosted_state_from_pins(
+                let redirect_state =
+                    (!args.common.is_global()).then_some(crate::commands::hosted_state_from_pins(
                         &socket_patch_core::patch::redirect::upstream::HostedPin::all(
                             ctx.discovery().await,
                         ),
-                    ),
-                );
+                    ));
                 if let Some(state) = redirect_state_json(redirect_state.as_ref(), &[]) {
                     result["redirectState"] = state;
                 }
@@ -2190,7 +2197,8 @@
         // A report-only run selects nothing, but a severity floor or
         // `enabled: false` still hides candidates; report them like the
         // human arm does (the detail fetch runs only then).
-        if !apply && !vendor && policy.reports_selection() && !all_packages_with_patches.is_empty() {
+        if !apply && !vendor && policy.reports_selection() && !all_packages_with_patches.is_empty()
+        {
             if let Err((code, message)) = discover_selected(
                 &api_client,
                 &all_packages_with_patches,
@@ -2483,12 +2491,7 @@
                     &all_packages_with_patches,
                     None,
                 );
-                updates = offer_updates(
-                    &rows,
-                    &discovered,
-                    &recorded,
-                    &all_packages_with_patches,
-                );
+                updates = offer_updates(&rows, &discovered, &recorded, &all_packages_with_patches);
                 rows
             }
             // `discover_selected` already printed the failure to stderr.
@@ -2950,14 +2953,20 @@
             dirs.iter()
                 .map(|(d, explicit)| {
                     (
-                        d.strip_prefix(tmp.path()).unwrap().to_string_lossy().replace('\\', "/"),
+                        d.strip_prefix(tmp.path())
+                            .unwrap()
+                            .to_string_lossy()
+                            .replace('\\', "/"),
                         *explicit,
                     )
                 })
                 .collect()
         };
-        let got = project_dirs(tmp.path(), &["apps/*".into(), "libs/core".into(), "apps/web".into()])
-            .unwrap();
+        let got = project_dirs(
+            tmp.path(),
+            &["apps/*".into(), "libs/core".into(), "apps/web".into()],
+        )
+        .unwrap();
         // Named literally = explicit (also when a glob matches it too).
         assert_eq!(
             rel(got),

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/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs
--- a/crates/socket-patch-cli/src/commands/scan/render.rs
+++ b/crates/socket-patch-cli/src/commands/scan/render.rs
@@ -726,7 +726,10 @@
 
     #[test]
     fn report_only_hint_names_agent_mode() {
-        assert_eq!(report_only_hint()[0], "To apply these patches in place, run:");
+        assert_eq!(
+            report_only_hint()[0],
+            "To apply these patches in place, run:"
+        );
         assert!(report_only_hint()[1].contains("--mode agent"));
     }
 

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/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() {
... diff truncated: showing 800 of 6421 lines

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

Comment thread crates/socket-patch-core/src/crawlers/python_crawler.rs
Comment thread crates/socket-patch-core/src/crawlers/python_crawler.rs
PDM_PYTHON outranks .pdm-python, so discovery now follows it too. A
project with uv.lock or poetry.lock is installed by uv or Poetry
(they drive hosted installs ahead of pdm.lock), so a leftover PDM
record or __pypackages__ there no longer decides the env. Found by
Bugbot review.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


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 2, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Ready for review at head 60dfb81.

  • CI: 340/340 check runs completed green (success/skipped) on 60dfb81; branch is 0 behind main, no conflicts.
  • Bugbot: reviewed 60dfb81 — no new issues. Both earlier findings on python_crawler.rs (lines ~425, ~485) were addressed and their threads are resolved.
  • Reviewer focus: the new uv UV_PROJECT_ENVIRONMENT and PDM .pdm-python / __pypackages__ env discovery paths in crates/socket-patch-core/src/crawlers/python_crawler.rs.

Slack announcement: not sent this run (Slack send tool unavailable to the agent); will retry next run.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Reviewed 60dfb81867f5de8f88132f20a829a4c18989dc16. Recommendation: fix before merging.

  • [P2] Exclude lockless Poetry projects from the uv override — crates/socket-patch-core/src/crawlers/python_crawler.rs:530. [tool.poetry] / poetry.toml already identify Poetry projects, but other_manager ignores them. With no poetry.lock, an ambient UV_PROJECT_ENVIRONMENT now wins over the installed Poetry .venv, so apply and stale-install/VEX checks inspect an unrelated environment. Reuse manager detection before assuming a lockless pyproject belongs to uv.
  • [P2] Resolve PDM virtualenv precedence before PEP 582 — crates/socket-patch-core/src/crawlers/python_crawler.rs:466. With PDM_IGNORE_SAVED_PYTHON=1 (or no saved interpreter), any leftover __pypackages__ now wins over VIRTUAL_ENV. PDM checks active/project virtualenvs first when python.use_venv is enabled. A migrated project can patch its stale tree and leave the active environment untouched.

Validation: the existing crawler suite passed (57 tests). Two focused review tests for these layouts both pass against the merge-base crawler and fail on this head, returning the unrelated uv env / stale PEP 582 directory respectively. Full workspace/platform matrix not rerun.

A Poetry project without poetry.lock ([tool.poetry] or poetry.toml)
is Poetry's, so an ambient UV_PROJECT_ENVIRONMENT no longer takes it
over. With no saved PDM interpreter, PDM uses an activated or
in-project venv before PEP 582, so __pypackages__ is now the last
resort instead of beating VIRTUAL_ENV and ./.venv. Found in review.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Both review findings are confirmed and fixed in 5ecac79.

  • [P2] Lockless Poetry vs the uv override: the uv probe now treats poetry.toml and a [tool.poetry] table in pyproject.toml as Poetry's, along with the existing lock and record markers. So an ambient UV_PROJECT_ENVIRONMENT no longer takes over a lockless Poetry project, and its .venv stays the env. New assertions are in uv_project_environment_is_the_project_env.
  • [P2] PDM venv precedence before PEP 582: step 0 now handles PDM only when there is an interpreter to follow (PDM_PYTHON, or the saved one unless PDM_IGNORE_SAVED_PYTHON). With no interpreter, the generic probes decide (VIRTUAL_ENV, then ./.venv, matching PDM's active/project venv order), and __pypackages__ is only the last resort when they find nothing (PDM 1.x). A saved base interpreter still means PEP 582. New assertions in pdm_pep582_pypackages_is_the_project_env cover an activated venv, an in-project .venv, PDM_IGNORE_SAVED_PYTHON with a .venv, and a saved base interpreter.

Validation on 5ecac79: core python_crawler tests 57/57, plus the CLI suites in_process_python_envs 17/17, in_process_redirect_pdm 5/5 and scan 104/104. cargo clippy --workspace --all-features -- -D warnings is clean. The PDM docs now describe the new order.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Re-reviewed 5ecac794d24527c40ec14ba2876c9d46ab9e948e. Both earlier findings are fixed, and both independent regression probes now pass. One follow-up regression remains before merge:

  • [P2] Preserve lockless PDM ownership before probing the uv override — crates/socket-patch-core/src/crawlers/python_crawler.rs:541. The PEP 582 fallback now runs after generic discovery, but the intervening uv probe still does not recognize [tool.pdm] / .pdm.toml. A lockless PDM project with no saved interpreter and an installed __pypackages__/3.12/lib is therefore redirected to an unrelated ambient UV_PROJECT_ENVIRONMENT. This worked on 60dfb818; the same focused test now returns the uv environment instead of the PDM package directory. Reuse PDM project detection in the uv ownership check, retaining explicit uv.lock precedence.

Validation: 57 crawler tests passed, both original reviewer probes passed, and the new lockless-PDM probe passes on 60dfb818 but fails on this head. Full workspace/platform matrix not rerun. Recommendation: fix this remaining case before merging.

A PDM project with no lock and no saved interpreter ([tool.pdm] or
.pdm.toml) is still PDM's, so an ambient UV_PROJECT_ENVIRONMENT no
longer claims it before the PEP 582 fallback; uv.lock still wins. A
[tool.pdm.build] table alone (the pdm-backend build backend) no longer
marks a project as PDM-managed. Found in review.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Confirmed and fixed in 2a22d4e: lockless PDM projects now keep ownership before the uv probe runs.

  • The uv ownership check now reuses is_pdm_project (pdm.lock, .pdm.toml, or [tool.pdm]), so an ambient UV_PROJECT_ENVIRONMENT no longer claims a lockless PDM project ahead of the PEP 582 fallback. Explicit uv.lock still wins.
  • is_pdm_project no longer treats a [tool.pdm.build] table on its own as PDM-managed, since that only configures the pdm-backend build backend, which uv projects use too. Without this, the change above would have dropped UV_PROJECT_ENVIRONMENT for those uv projects.
  • New assertions: pdm_pep582_pypackages_is_the_project_env covers a lockless [tool.pdm] project with __pypackages__/3.11/lib plus an ambient uv env, which now resolves to the PDM lib dir. uv_project_environment_is_the_project_env covers lockless [tool.pdm] (the uv env is ignored) and [tool.pdm.build]-only (the uv env is used).

Validation on 2a22d4e: core python_crawler 57/57, in_process_python_envs 17/17, in_process_redirect_pdm 5/5, scan 104/104. cargo clippy --workspace --all-features -- -D warnings is clean.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


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-core/src/crawlers/python_crawler.rs
Comment thread crates/socket-patch-core/src/crawlers/python_crawler.rs
PDM skips an activated venv when PDM_IGNORE_ACTIVE_VENV is set (CI,
tox), so for a PDM project the VIRTUAL_ENV probe now does too, and
the project venv or __pypackages__ decides. Found by Bugbot.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


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) commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Closing follow-up for aabaf5b59cca24ca7d107fe437b69baf81aa5b0e: the remaining false-valued PDM flag finding is fixed; code review is clear, pending CI. The narrow final commit routes both PDM flags through boolean parsing that matches PDM's parser; I also verified that PDM uses it for both flags. Added discovery assertions cover false values for the active environment and saved interpreter.

Validation scope: the preceding reviewed heads passed 57 crawler tests and all three earlier independent precedence probes. The false-flag reproduction failed at db9160b3, establishing the final change's cause. I reviewed the complete final single-file delta and its regression assertions against upstream semantics; I did not rerun the local suite for this last commit. Let the checks at this SHA finish before merging. This supersedes the earlier changes-needed recommendation.

Resolve the python_crawler.rs conflict with the Poetry env selection
fix (#527): keep main's poetry_active_prefix for VIRTUAL_ENV and add
the PDM_IGNORE_ACTIVE_VENV gate on top.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

PDM treats 0, false and no (any case) as false for
PDM_IGNORE_ACTIVE_VENV and PDM_IGNORE_SAVED_PYTHON. Discovery treated
any non-empty value as true, so PDM_IGNORE_ACTIVE_VENV=0 skipped the
activated venv PDM uses. Found in review.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Confirmed and fixed in aabaf5b: false-valued PDM_IGNORE_ACTIVE_VENV is now parsed the way PDM parses it.

  • A new pdm_env_flag helper mirrors PDM's ensure_boolean: the variable is set, non-empty, and not false / no / 0 (any case). It now backs both PDM_IGNORE_ACTIVE_VENV and PDM_IGNORE_SAVED_PYTHON, which had the same non-empty-string bug.
  • New assertions:
    • pdm_pep582_pypackages_is_the_project_env: PDM_IGNORE_ACTIVE_VENV set to 0, false, No or empty keeps the activated venv. 1 still skips it.
    • pdm_saved_interpreter_venv_is_the_project_env: PDM_IGNORE_SAVED_PYTHON set to 0, false or NO keeps .pdm-python.

Validation on aabaf5b, which includes the main merge db9160b: core python_crawler 60/60, in_process_python_envs 17/17, in_process_redirect_pdm 5/5 and scan 104/104. cargo clippy --workspace --all-features -- -D warnings is clean.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


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: Base PDM_PYTHON skips project venv
    • Restructured the logic to return None when PDM_PYTHON points to a base interpreter, allowing generic discovery to find the actual project venv instead of incorrectly falling back to PEP 582 mode.

Create PR

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

Comment thread crates/socket-patch-core/src/crawlers/python_crawler.rs
When PDM_PYTHON is set to a base/system Python (not a venv), the code
previously fell back to PEP 582 mode (__pypackages__), which is incorrect.

PDM resolves the interpreter and install environment separately:
- If the interpreter is already a venv/conda env, PDM installs there
- Otherwise, with default python.use_venv=true, PDM uses the project venv
- PEP 582 is only used when no interpreter is saved and use_venv=false

The fix restructures the logic to:
1. Check PEP 582 only when there's NO interpreter at all
2. Use a venv if the interpreter is detected as one
3. Return None (for generic discovery) if the interpreter is a base Python

This allows generic probes to find the actual project venv instead of
incorrectly returning stale PEP 582 dirs or missing out-of-tree venvs.
A base interpreter in PDM_PYTHON (CI's system Python) no longer
overrides the saved venv: the first recorded interpreter that is an
environment wins, and conda envs (conda-meta/) count as environments.
A base interpreter means PEP 582 only when python.use_venv is off
(PDM_USE_VENV, or pdm.toml / .pdm.toml; off by default only on PDM
1.x). Otherwise the venv probes decide. Found by Bugbot.

Assisted-by: Claude Code:claude-opus-5-5
Bugbot's autofix (8c9d28d) and ca62351 fix the same finding. Keep
ca62351: the autofix returned __pypackages__ ahead of an activated or
in-project venv when no interpreter is recorded, and dropped the
PEP 582 case for a base interpreter with python.use_venv off (#528).

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


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 2b5c881. Configure here.

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