Skip to content

Fix Pipenv ignoring pylock patch wiring (#912, #1122) - #1193

Merged
Mikola Lysenko (mikolalysenko) merged 6 commits into
mainfrom
agent/fix-pipenv-pylock-consumer
Oct 9, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 6 commits into
mainfrom
agent/fix-pipenv-pylock-consumer

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

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

Fixes #912
Fixes #1122

Summary

A pylock.toml (or pylock.<name>.toml) beside a Pipfile is installed
by Pipenv, not by a PEP 751 installer. Every pylock code path treated it as
installer-neutral, so both modes could report success while Pipenv installed
the unpatched release.

Shared root cause

Nothing asked whether a Pipfile beside the pylock makes Pipenv the
installer:

Fix

The rule is one predicate, utils::python_lock::pipenv_reads_pylock: a pylock
beside a Pipfile, with no Pipfile.lock and no governing uv/Poetry/PDM lock. Three call sites use it:

  1. Vendored router: with a Pipfile, the pylocks belong to Pipenv.
    • With Pipfile.lock present, Pipfile.lock is wired (pipenv flavor) and
      the pylock is named in the pypi_multiple_lockfiles warning.
    • With no Pipfile.lock, the scan refuses with
      pypi_pipenv_pylock_unsupported and a pipenv lock remedy.
    • A higher-ranked tool lock (uv/poetry/pdm) routes as before.
  2. Hosted pylock rewriter: a pylock Pipenv reads is refused with
    redirect_pipenv_pylock_unsupported. Nothing is written, and the uuid
    lands in refused_python_lock_uuids, so it is not counted as redirected.
    With Pipfile.lock present both locks are still pinned, as before.
  3. VEX discovery: a Socket reference in such a pylock is diagnosed
    (DIAG_REF_INVALID), not discovered. Projects vendored before this fix no
    longer get a false not_affected.

The Pipenv compatibility doc gets a row for both layouts. The npm, PyPI and
gem wrappers only dispatch to the binary, so they need no change.

Tests (red → green)

Issue Test Without fix
#912 hosted patch::redirect::pipenv_pylock_tests::pylock_read_by_pipenv_is_refused_without_pipfile_lock (pylock.toml + pylock.dev.toml) pinned pylock.toml
#912 vendored vendor::pypi::tests::pipenv_pylock_without_pipfile_lock_refuses Ok(PythonLocks)
#912 vex vex::discover::pypi_locks::tests::pylock_read_by_pipenv_is_not_trusted (hosted + vendored refs) ref discovered
#1122 router vendor::pypi::tests::pipenv_pylock_beside_pipfile_lock_routes_to_pipenv PythonLocks
#1122 cycle vendor::pypi::tests::pipenv_use_pylock_project_vendors_into_pipfile_lock (vendor → Pipfile.lock wired, pylock byte-identical → revert) wrong lock wired
controls pylock_without_a_pipenv_consumer_or_beside_pipfile_lock_still_pins passed before and after
Bugbot: stray Pipfile beside a governing uv/Poetry/PDM lock pylock_beside_a_governing_tool_lock_is_not_pipenvs (hosted), pylock_beside_a_governing_tool_lock_is_trusted (vex) hosted refused the uuid and vetoed the uv.lock pin; vex diagnosed the ref
both, real Pipenv 2026.8.0 e2e_vex_build pipenv::pipenv_pylock_projects_wire_what_pipenv_installs: pylock-only refused in both modes with the pylock untouched; use_pylock vendored → fresh pipenv install --deploy installs the PATCHED six new

Commands run locally:

  • cargo clippy --workspace --all-features -- -D warnings: clean.
  • cargo test -p socket-patch-core --all-features --lib: 5841 pass. 4 fail, all permission-denial tests in modules this PR doesn't touch (copy_tree, vlt_heal, pypi_poetry/pypi_requirements write-failure). They can't fail as root in this sandbox; CI runs unprivileged.
  • cargo test -p socket-patch-cli --all-features --lib plus every pypi in_process_*/mode_migration_pypi/hosted_superseding_pypi/policy_pypi_names suite: all pass except pipenv_hosted_to_vendored_names_the_unpatched_requirements. It needs the live PyPI JSON API, which this sandbox could not reach (error sending request for url (https://pypi.org/pypi/six/1.16.0/json)).
  • SOCKET_PATCH_PIPENV_E2E_REQUIRED=1 SOCKET_PATCH_PIPENV_E2E_VERSIONS=2026.8.0 cargo test -p socket-patch-cli --all-features --test e2e_vex_build -- --ignored pipenv::pipenv_pylock: passes.
  • cargo fmt: only this PR's hunks are formatted. Main currently has rustfmt drift under the pinned 1.93.1 toolchain, and CI runs no fmt check, so I left the unrelated files alone.

Notes

  • vex_pipenv_pip_real::bootstrap_tool is now serialized. The new e2e is the Pipenv suite's second test, and on a cold tools cache the two raced uv venv on the same tool venv (failed in CI, reproduced 5/5 locally, 3/3 green after).
  • This PR does not touch CHANGELOG.md.
  • Out of scope: wiring both Pipfile.lock and the pylock in vendored mode,
    the way hosted mode does. The pylock is unused by Pipenv while
    Pipfile.lock exists, so this PR names it loudly instead.

🤖 Generated with Claude Code


Note

Medium Risk
Changes PyPI patch routing, redirect, and VEX trust for Pipenv/pylock projects—incorrect detection could refuse valid pins or miss unsafe ones, but behavior is heavily test-covered.

Overview
Fixes false success when a PEP 751 pylock*.toml sits next to a Pipfile: Pipenv (not a PEP 751 installer) either ignores the pylock when Pipfile.lock exists (#1122) or reads the pylock but drops hosted/vendored archive entries (#912).

A shared helper pipenv_reads_pylock drives the behavior at three layers: hosted pylock redirect refuses with redirect_pipenv_pylock_unsupported when only pylock + Pipfile exist; vendored PyPI routing wires Pipfile.lock when both locks are present (pylock named as extra lockfile) or errors with pypi_pipenv_pylock_unsupported on pylock-only checkouts; VEX discovery treats Socket refs in those pylocks as invalid instead of attesting upstream installs. Governing uv/poetry/pdm locks still win over a stray Pipfile.

Adds unit/integration tests, a Pipenv 2026 use_pylock ignored e2e, doc row in pipenv compatibility, and a mutex on test tool bootstraps to avoid parallel uv venv races.

Reviewed by Cursor Bugbot for commit 2cb9316. Configure here.


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
A pylock.toml (or pylock.<name>.toml) beside a Pipfile is read by
Pipenv, not by a PEP 751 installer, and every pylock code path treated
it as installer-neutral:

- Vendored scans of a `use_pylock = true` project (Pipfile, Pipfile.lock
  and pylock.toml) wired only pylock.toml. Pipenv installs from
  Pipfile.lock when both exist, so every `pipenv sync` and
  `install --deploy` got the unpatched release after a "success" run
  (#1122). The router now wires Pipfile.lock and names the pylock in
  the pypi_multiple_lockfiles warning.
- Hosted and vendored scans of a pylock-only Pipenv checkout wrote an
  `archive` entry that Pipenv's pylock reader drops, so `pipenv sync`
  installed the upstream release while the scan reported success, and
  vendored VEX attested not_affected (#912). Both modes now refuse with
  a `pipenv lock` pointer (redirect_pipenv_pylock_unsupported /
  pypi_pipenv_pylock_unsupported), and VEX discovery no longer trusts a
  Socket reference in such a pylock.

The rule lives in one predicate, utils::python_lock::pipenv_reads_pylock,
shared by the hosted rewriter, the vendored router and VEX discovery.

Assisted-by: Claude Code:claude-opus-5-5
Adds a real-Pipenv 2026 e2e (part of the existing e2e_vex_build
pipenv:: legs) for a `[pipenv] use_pylock = true` project:

- with Pipfile.lock and pylock.toml, vendored mode wires Pipfile.lock
  and a fresh `pipenv install --deploy` gets the patched release
  (#1122);
- the pylock-only checkout is refused in hosted and vendored mode and
  the pylock is left untouched (#912).

Documents both cases in the Pipenv compatibility table.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 9, 2026 00:59
@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: Pylock refusal poisons sibling confirmation
    • Modified pipenv_reads_pylock to check for uv.lock and poetry.lock presence, preventing pylock refusal when higher-priority tool locks govern the project.

Create PR

Or push these changes by commenting:

@cursor push d597b38e73
Preview (d597b38e73)
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))
 }
 
 impl Default for CargoCrawler {

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, mark_pinned, 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
@@ -79,9 +79,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;
@@ -567,7 +567,7 @@
         bun_lockb_present,
         &std::collections::BTreeSet::new(),
         &std::collections::BTreeSet::new(),
-     &yarnrc::OuterYarnMirror::default(),
+        &yarnrc::OuterYarnMirror::default(),
     )
 }
 
@@ -6803,8 +6803,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))
@@ -10876,7 +10878,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"),
@@ -10892,7 +10897,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:?}");
         }
     }
@@ -10917,7 +10925,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/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs
--- a/crates/socket-patch-core/src/utils/python_lock.rs
+++ b/crates/socket-patch-core/src/utils/python_lock.rs
@@ -186,7 +186,8 @@
 /// #912 / #1122: whether Pipenv installs from the PEP 751 lock `rel` (a
 /// project-relative path). A `Pipfile` beside the lock makes Pipenv the
 /// installer, and Pipenv reads a pylock only when that directory has no
-/// `Pipfile.lock`. Its reader (`PylockFile.convert_to_pipenv_lockfile`)
+/// `Pipfile.lock` and no higher-priority tool lock (`uv.lock` or
+/// `poetry.lock`). Its reader (`PylockFile.convert_to_pipenv_lockfile`)
 /// keeps a package's version, marker and wheel / sdist hashes and nothing
 /// else, so no hosted or vendored `archive` entry survives it: such a lock
 /// can't carry a patch. `exists` answers for project-relative paths.
@@ -198,6 +199,8 @@
     is_pep751_lock_name(name)
         && exists(&format!("{dir}Pipfile"))
         && !exists(&format!("{dir}Pipfile.lock"))
+        && !exists(&format!("{dir}uv.lock"))
+        && !exists(&format!("{dir}poetry.lock"))
 }
 
 pub fn python_lock_paths(root: &Path) -> std::io::Result<Vec<String>> {

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/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/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/patch/redirect/mod.rs
A Pipfile left beside a governing uv.lock, poetry.lock or pdm.lock
does not make Pipenv the installer, but the hosted pylock rewriter and
VEX discovery still treated the pylock as Pipenv's. The hosted refusal
then marked the patch refused for every Python lock, so a successful
uv.lock pin was reported as not redirected and dropped from VEX.

pipenv_reads_pylock now applies the same tool-lock precedence the
vendored router already used, so all three call sites agree.

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.

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

Copy link
Copy Markdown
Collaborator Author

[agent] gradle 8.14.3 / jdk 24 / vendor / jdk-ceiling failed on f8b0ec7 before any test ran. It was the Install Maven step: Maven Central answered six retries for apache-maven-3.9.16-bin.tar.gz with 404 (it returns 429 when probed now). This PR touches no CI or JVM code, so the failure isn't from this PR. The fallback fix is in #1166 (Apache-archive fallback plus pinned SHA512s). That is a CI-infra change bundled with release sharding, so I'm not porting it into this Pipenv fix. I'll re-run the job once when the Gradle workflow finishes.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] A second check failed before any test ran: image in sbt / Mill / scala-cli compatibility, on f8b0ec7. Building tests/docker/Dockerfile.sbt failed because the Mill 0.11.13 launcher download from GitHub releases returned 404. curl's --retry does not retry a 404. The same URL serves the file now, and main passed this workflow at 793edd4 with an identical Dockerfile. This PR doesn't touch the Dockerfile or the workflow, so the failure isn't from this PR, and I don't know of an existing fix for 404 retries in that step. As with the Gradle job above, I'll re-run it once when its workflow finishes.


Generated by Claude Code

bootstrap_tool checks for a healthy tool venv and then creates it, with
no lock in between. The Pipenv e2e suite now has two tests, and libtest
runs them in parallel. On a cold tools cache both miss the probe and
race `uv venv` on the same pipenv-<version> directory, so one fails with
"a virtual environment already exists". This reproduced in CI and 5/5
times locally from an empty tools root. A process-wide lock around the
check-and-create fixes it; warm-cache runs only pay for a --version probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzAvnBX6HySvR6zXsFtdy7
@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 2cb9316. Configure here.

@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

Ready for review (burn-down agent) at 2cb93163.

  • CI: 466/466 checks green (451 success, 15 skipped).
  • Bugbot: reviewed 2cb9316, no findings (check success). The one earlier review thread (redirect/mod.rs:5257) is resolved.
  • Mergeable, no CHANGELOG change.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief

What it does. When a pylock*.toml sits beside a Pipfile, Pipenv installs the project, not a PEP 751 installer. Pipenv ignores the pylock when Pipfile.lock exists. Without Pipfile.lock, it reads the pylock but drops the archive entry, so the patched pin never installs. A new check, pipenv_reads_pylock, now drives three paths:

  • Hosted redirect: refuses with redirect_pipenv_pylock_unsupported.
  • Vendored: wires Pipfile.lock and warns about the pylock, or refuses with pypi_pipenv_pylock_unsupported when there is no Pipfile.lock.
  • VEX: reports a Socket ref in that pylock as invalid instead of trusting it.

Risk: low. It only fires for a pylock beside a Pipfile when no uv, Poetry or PDM lock governs the project. When it fires, the outcome is a refusal, never a wrong write. The case where both locks exist pins both, as before.

Look here

  • python_lock.rs:195: pipenv_reads_pylock. It checks for a uv, Poetry or PDM lock before it checks Pipfile.lock.
  • vendor/pypi.rs:314: the vendored split into refuse or warn-and-wire.
  • redirect/mod.rs:5248: the hosted refusal. It is taken only when the plan would pin, and is recorded in refused_python_lock_uuids.
  • vex/discover/pypi_locks.rs:621:`` pylock_pairing, the VEX side.
  • e2e_vex_build/pipenv.rs:365: the real-Pipenv e2e. pipenv install --deploy installs the patched six.

Verified

  • I read the full diff.
  • Hosted and vendored apply the same rule. Hosted uses pypi_tool_lock_shadowed("Pipfile.lock") and vendored uses the governing-lock match, and the two are equivalent given the order of PYPI_TOOL_LOCKS. Pipfile is in the hosted candidate list (formats/registry.rs:113), so the check fires in production.
  • Lock paths are root-level only, so Windows separators and subdirectories are not a concern.
  • The new tests assert codes that don't exist on main, so they fail there.
  • No debug leftovers. CHANGELOG.md is untouched. The branch merges cleanly onto current main.
  • CI: 467/467 check runs on the head (451 success, 16 skipped), including ci-ok and clippy. Bugbot is clean and its one thread is resolved.

Changes I made: none.

Open questions (not blocking): a project vendored before this fix in the #1122 layout (Pipfile + Pipfile.lock + pylock.toml) still has its vendored ref in the pylock, and VEX still reports it. VEX deliberately reads every lock, including shadowed ones (vex/discover/pypi_locks.rs:5-8), so this is an existing design limit, not a regression. It is worth a follow-up issue if old checkouts matter.

Auto-merge (squash) is armed, so approving sends this straight to the merge queue.


Generated by Claude Code

@mikolalysenko
Mikola Lysenko (mikolalysenko) added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit b41b9d3 Oct 9, 2026
467 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the agent/fix-pipenv-pylock-consumer branch October 9, 2026 07:19
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Oct 9, 2026
…elease notes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

3 participants