Repository navigation
Vendored uv revert writes the pre-vendor specifier back into uv.lock after the user changes the vendored package's version spec, so uv sync --locked fails (vendor --revert / remove / rollback exit 0) #840
Description
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
When vendored mode wires six through [tool.uv.sources], it rewrites six's entry in uv.lock's [package.metadata] requires-dist (or requires-dev) from { name = "six", specifier = "==1.16.0" } to { name = "six", path = ".socket/vendor/…" }. This matches uv, which drops the specifier for a path source (path_source_entry, crates/socket-patch-core/src/vendor/pypi_uv.rs:1450). It saves the old element as the record's original.
Because the specifier isn't in the lock any more, a later user edit to six's own requirement (uv add "six>=1.16,<1.17", or a hand edit plus uv lock) leaves uv.lock byte-identical. uv correctly sees nothing to relock, and uv sync --locked passes. The vendored record therefore still matches exactly, and vendor --revert / remove / rollback replace it with the saved original (pypi_uv.rs:830, through toml_surgery::replace_fragment, toml_surgery.rs:232). That writes back the stale specifier = "==1.16.0", while pyproject.toml now says six>=1.16,<1.17.
Result: both files lose every .socket/vendor reference and the command reports success (exit 0, no warning). But uv.lock's metadata no longer matches pyproject.toml, so uv sync --locked fails with "The lockfile at uv.lock needs to be updated".
Hosted mode does the right thing on the same shape. The vendored → hosted takeover (scan --mode hosted) and a hosted rollback after a single-clause edit both re-derive the current specifier from pyproject.toml, and --locked passes. Only the vendored revert path restores a recorded specifier blindly.
This is not #806 / #821. Those are about a relock that re-serializes an array, so the lock record drifts and only pyproject.toml is reverted. Here nothing in uv.lock drifts, both files are reverted, and the restored content itself is wrong. PR #822 (77686c4) fixes #806 / #821 but not this: verified, same failure.
Impact
- Any project that tightens or relaxes the vendored package's requirement while the patch is vendored (a routine
uv add pkg>=…) gets a broken frozen/locked install the moment the patch is reverted (vendor --revert,remove,rollback, and the GC pass behindscan --prune). CI onuv sync --lockedgoes red, while socket-patch reports success. - A plain
uv syncsilently relocks, which hides the corruption locally and makes it look like a lock churn caused by the user.
Repro
The mock serves POST /patch/package and the patched six 1.16.0 wheel with SRI sha512. It's the same harness as #806 / #821. uv resolves everything else from PyPI.
mkdir app && cd app
cat > pyproject.toml <<'EOF'
[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "attrs>=20"]
EOF
uv lock && uv sync
SP="socket-patch --api-url $MOCK --api-token fake --org acme --patch-server-url $MOCK --vendor-url $MOCK"
$SP scan --mode vendored --yes # uv.lock requires-dist: { name = "six", path = ".socket/vendor/…" }
uv add "six>=1.16,<1.17" # pyproject changes; uv.lock is byte-identical (cmp says so)
uv sync --locked # ok
$SP vendor --revert --yes --json # exit 0, status "success", no warning
grep 'name = "six", specifier' uv.lock # { name = "six", specifier = "==1.16.0" } <- stale
grep six pyproject.toml # "six>=1.16,<1.17"
uv sync --locked # error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.The same happens with six in a PEP 735 dev group (requires-dev), with a hand edit plus uv lock instead of uv add, and with remove pkg:pypi/six@1.16.0 / rollback pkg:pypi/six@1.16.0 in place of vendor --revert. Other specifiers fail too (>=1.15, ==1.16.*).
Expected vs actual
- Expected: the revert leaves a lock that matches the current pyproject.toml. Either it re-derives the requires-dist / requires-dev element from the current declaration, as hosted unwind already does (
redirect/upstream/uv.rs), or, if it can't, it treats the mismatch as drift and keeps both files (docs/testing/uv-compatibility.md: "Script and lock edits are treated as a pair… rather than restoring only one side"; CLI_CONTRACT.md: user-changed fragments are left alone withvendor_lock_entry_drifted). A revert should never report success on a project whoseuv sync --lockedit just broke. - Actual: the stale
originalelement is written back verbatim, exit 0,status: "success", no events, anduv sync --lockedfails.
Matrix
On main 045d7ec, "fail" means 0 vendor refs in both files, uv.lock specifier ==1.16.0, and uv sync --locked exits non-zero. Each cell is {hand edit + uv lock, uv add} × {dependencies, dev group} × {vendor --revert, remove}. "H takeover" is scan --mode hosted on the same edited project.
| OS | uv 0.5.31 | uv 0.8.17 | uv 0.12.23 | H takeover (all three) |
|---|---|---|---|---|
| Linux (sandbox + ubuntu-latest) | fail (8/8) | fail (8/8) | fail (8/8) | pass |
| macOS (macos-latest) | fail (8/8) | fail (8/8) | fail (8/8) | pass |
| Windows (windows-latest) | fail (8/8) | fail (8/8) | fail (8/8) | pass |
Linux, PR #822 77686c4, vendor --revert (deps, dev) |
– | – | fail | – |
Not affected: a marker edit (six==1.16.0; python_version >= "3.9") on uv 0.8.17 / 0.12.23, because uv records nothing new and the restored ==1.16.0 still matches. On 0.5.31 the marker lands in the lock, which is the drift / half-revert case covered by #822.
Not bisected. The behaviour is inherent to the recorded-original design of the uv vendored backend and is present in every release on the matrix.
Suspect code
crates/socket-patch-core/src/vendor/pypi_uv.rs:826-845: theuv_lock_requires_dist/uv_lock_requires_devrevert arm restoresrec.originalwheneverrec.newis still present, with no check that the original'sspecifierstill matches pyproject.toml's declaration.crates/socket-patch-core/src/vendor/pypi_uv.rs:1450(path_source_entry): the specifier is dropped (correctly, matching uv), which is why no lock drift can ever signal the user's edit.
Probe run: https://github.com/SocketDev/socket-patch/actions/runs/37284791550 (bughunt/uv/20261005-spec-edit-revert, 3 OS × 0.5.31 / 0.8.17 / 0.12.23).
Activity
uv: run 19 ledger (#840; #806/#821 verified on #822)
mikolalysenko commentedon Oct 5, 2026
[agent] Triaged as
priority:p1(uv). It's not a duplicate, and no open or merged PR fixes it. On PR #822's head (77686c4),revert_uvinvendor/pypi_uv.rsstill restores the recordeduv_lock_requires_distelement (and theuv_lock_requires_devgroup array) verbatim. Nothing compares the recordedspecifierwith the declaration pyproject.toml has now. The cause is separate from #806 / #821 (array re-serialization, plus the missing pair gate), but the fix has to touch the same function, so it will be stacked on #822.Generated by Claude Code
mikolalysenko commentedon Oct 5, 2026
[agent] Claiming this issue (shared root cause: vendored uv revert restores the recorded lock specifier without re-deriving it from pyproject.toml). Branch: agent/fix-uv-revert-stale-specifier. Claim-ID: 20261005T092920Z-7a618f
Generated by Claude Code
mikolalysenko commentedon Oct 5, 2026
[agent] Draft fix PR: #841 (stacked on #822).
Generated by Claude Code
Test uv revert after a declaration edit
Re-derive uv specifiers on vendored revert
Document uv revert after a declaration edit
Adapt uv specifier re-derivation to main
Pick the declaration a uv lock entry mirrors
Tighten uv revert specifier re-derivation
Fix uv revert restoring a stale specifier (#840) (#841)