[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
When a uv project declares its existing sources as dotted keys inside [tool.uv] (sources.localpkg = { path = "./localpkg" } or sources.localpkg.path = "./localpkg"), vendored mode writes the patched package's source the same way: sources.six = { path = ".socket/vendor/pypi/<uuid>/six-….whl" }. But the wiring ledger (.socket/vendor/state.json) records the line as six = { path = "…" }. vendor --revert and remove look for that exact line, don't find it, and treat it as third-party drift (vendor_lock_entry_drifted). They still restore uv.lock byte for byte, but they leave the sources.six line in pyproject.toml and keep the artifact directory.
Impact
- The project is left inconsistent:
pyproject.toml still routes six to the vendored wheel, while uv.lock is back to the registry entry. uv sync --locked fails with "The lockfile at uv.lock needs to be updated" (exit 1 on 0.8.17 / 0.12.22, exit 2 on 0.5.31). Frozen CI breaks after an unwind.
- A plain
uv sync re-locks to the vendored wheel, so the "reverted" project silently stays patched.
vendor --revert reports status: success and exits 0. remove reports partialFailure. Re-running either never converges: the same drift is reported every time. No user edit happened, so the "undo the drift and re-run" hint can't be followed.
Repro (Linux, main 61cfb9b, real uv 0.8.17)
mkdir -p localpkg/src/localpkg && touch localpkg/src/localpkg/__init__.py
printf '[project]\nname = "localpkg"\nversion = "0.1.0"\n[build-system]\nrequires = ["hatchling"]\nbuild-backend = "hatchling.build"\n' > localpkg/pyproject.toml
cat > pyproject.toml <<'EOF'
[project]
name = "proj"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "idna==3.7", "localpkg"]
[tool.uv]
sources.localpkg = { path = "./localpkg" }
EOF
uv lock && uv sync && cp pyproject.toml pyproject.orig && cp uv.lock uv.lock.orig
# .socket/manifest.json holds a free six 1.16.0 patch (local mock patch API serving the prebuilt wheel)
socket-patch vendor --json $API # adds: sources.six = { path = ".socket/vendor/pypi/<uuid>/six-1.16.0-py2.py3-none-any.whl" }
uv sync --locked # ok, six is patched
socket-patch vendor --revert --json $API # status success, exit 0, events: vendor_lock_entry_drifted, vendor_artifact_kept, vendor_revert_kept
cmp uv.lock uv.lock.orig # identical
diff pyproject.orig pyproject.toml # > sources.six = { path = ".socket/vendor/pypi/<uuid>/six-1.16.0-py2.py3-none-any.whl" }
uv sync --locked # error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
uv sync # re-locks to the vendored wheel: six stays patched
The drift message is pyproject.toml fragment for Some("six") changed since vendoring; left untouched, though nothing touched the file. state.json records "new": "six = { path = \".socket/vendor/pypi/<uuid>/six-1.16.0-py2.py3-none-any.whl\" }", and the file holds sources.six = { … }.
socket-patch remove <uuid> gives the same result: partialFailure, the lock is restored, and the sources.six line stays.
Expected vs actual
- Expected: CLI_CONTRACT / uv-compatibility.md: vendored revert restores the wiring it wrote, byte for byte. The
vendor_lock_entry_drifted skip is meant for real third-party edits (pypi_uv.rs revert doc: "revert must never clobber third-party edits"), not for socket-patch's own line. Hosted mode on the same project round-trips byte-identically (checked on this run).
- Actual: revert misreads its own line as drift and restores only half the project, leaving pyproject and the lock out of sync. It still exits 0.
OS × version
| Spelling of existing sources |
uv 0.5.31 |
uv 0.8.17 |
uv 0.12.22 |
[tool.uv] + sources.localpkg = { path = … } |
– |
❌ |
– |
[tool.uv] + sources.localpkg.path = … |
❌ |
❌ (reproduced 3×) |
❌ |
[tool.uv.sources] + localpkg = { … } (control) |
– |
✅ byte-identical |
– |
| hosted scan → rollback, both dotted spellings (control) |
– |
✅ byte-identical |
– |
The failure is a CLI-side text match, so it doesn't depend on the OS; uv only has to reject the inconsistent result. Not bisected.
Related but distinct: #524 (sub-table spelling leaves an empty header; its "dotted key comes back byte-identical" note covered hosted only) and #474 (real drift from uv add --script). Also, the inline spelling [tool.uv] + sources = { … } is refused up front with pypi_uv_lock_parse_failed: … is not a standard table, so it never reaches this path.
Suspect code
crates/socket-patch-core/src/vendor/pypi_uv.rs:602-610: toml_edit inserts the key into a dotted sources table, so it prints sources.six = …, but the record hard-codes format!("{canon_name} = {{ path = … }}").
crates/socket-patch-core/src/vendor/pypi_uv.rs:884 (remove_exact_line(&pyproject_text, new) in revert_uv): the exact-line match fails, and the "already converged" probe sees the uuid needle, so the line is reported as drift. Meanwhile the uv.lock records are reverted anyway, which causes the half-revert. One possible fix: record the line toml_edit actually emitted (or match it through the TOML document by key path). And when any pyproject record drifts, don't revert the lock alone.
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
When a uv project declares its existing sources as dotted keys inside
[tool.uv](sources.localpkg = { path = "./localpkg" }orsources.localpkg.path = "./localpkg"), vendored mode writes the patched package's source the same way:sources.six = { path = ".socket/vendor/pypi/<uuid>/six-….whl" }. But the wiring ledger (.socket/vendor/state.json) records the line assix = { path = "…" }.vendor --revertandremovelook for that exact line, don't find it, and treat it as third-party drift (vendor_lock_entry_drifted). They still restoreuv.lockbyte for byte, but they leave thesources.sixline inpyproject.tomland keep the artifact directory.Impact
pyproject.tomlstill routes six to the vendored wheel, whileuv.lockis back to the registry entry.uv sync --lockedfails with "The lockfile atuv.lockneeds to be updated" (exit 1 on 0.8.17 / 0.12.22, exit 2 on 0.5.31). Frozen CI breaks after an unwind.uv syncre-locks to the vendored wheel, so the "reverted" project silently stays patched.vendor --revertreportsstatus: successand exits 0.removereportspartialFailure. Re-running either never converges: the same drift is reported every time. No user edit happened, so the "undo the drift and re-run" hint can't be followed.Repro (Linux, main
61cfb9b, real uv 0.8.17)The drift message is
pyproject.toml fragment for Some("six") changed since vendoring; left untouched, though nothing touched the file.state.jsonrecords"new": "six = { path = \".socket/vendor/pypi/<uuid>/six-1.16.0-py2.py3-none-any.whl\" }", and the file holdssources.six = { … }.socket-patch remove <uuid>gives the same result:partialFailure, the lock is restored, and thesources.sixline stays.Expected vs actual
vendor_lock_entry_driftedskip is meant for real third-party edits (pypi_uv.rs revert doc: "revert must never clobber third-party edits"), not for socket-patch's own line. Hosted mode on the same project round-trips byte-identically (checked on this run).OS × version
[tool.uv]+sources.localpkg = { path = … }[tool.uv]+sources.localpkg.path = …[tool.uv.sources]+localpkg = { … }(control)The failure is a CLI-side text match, so it doesn't depend on the OS; uv only has to reject the inconsistent result. Not bisected.
Related but distinct: #524 (sub-table spelling leaves an empty header; its "dotted key comes back byte-identical" note covered hosted only) and #474 (real drift from
uv add --script). Also, the inline spelling[tool.uv]+sources = { … }is refused up front withpypi_uv_lock_parse_failed: … is not a standard table, so it never reaches this path.Suspect code
crates/socket-patch-core/src/vendor/pypi_uv.rs:602-610: toml_edit inserts the key into a dottedsourcestable, so it printssources.six = …, but the record hard-codesformat!("{canon_name} = {{ path = … }}").crates/socket-patch-core/src/vendor/pypi_uv.rs:884(remove_exact_line(&pyproject_text, new)inrevert_uv): the exact-line match fails, and the "already converged" probe sees the uuid needle, so the line is reported as drift. Meanwhile theuv.lockrecords are reverted anyway, which causes the half-revert. One possible fix: record the line toml_edit actually emitted (or match it through the TOML document by key path). And when any pyproject record drifts, don't revert the lock alone.