Repository navigation
Description
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
scan --mode hosted redirects a uv project where the patched package shows up in [project] with more than one specifier. Examples: dependencies = ["six==1.16.0"] plus [project.optional-dependencies] extra = ["six>=1.15"], two extras with different floors, or one dependencies list with marker-split specifiers. The scan succeeds (exit 0, redirected: 1, uv sync --locked installs the patch). After that, rollback, remove and the hosted → vendored takeover all refuse:
cannot restore pkg:pypi/six@1.16.0 to its upstream registry entry: uv.lock: pyproject.toml declares six with different specifiers; which one each lock entry mirrors is not derivable; restore it from version control instead (`git checkout -- uv.lock`)
The mapping is derivable, though. The hosted rewrite keeps each requires-dist entry's marker (extra == 'extra', python_version < '3.10') and only swaps specifier for url. Each lock entry can therefore be matched to the declaration with the same marker (plus extra == '<name>' for an optional group), which is how uv itself produced it.
Impact
A floor in dependencies with a tighter or looser one in an extra (or an all extra that re-lists packages) is a common pyproject layout. On such a project a hosted patch can be applied but not unwound by any socket-patch command: rollback and remove exit 1, and vendor / scan --mode vendored fail redirect_revert_failed, so the user can't switch modes either. The only way out is git checkout. Vendored mode on the same project vendors and reverts byte-identically.
Repro (main 045d7ec, Linux, uv 0.8.17)
This uses the same local mock patch API as the earlier uv issues (batch / by-package / package / view routes plus a patched six-1.16.0 wheel), --patch-server-url for the mock origin, and SOCKET_PYPI_JSON_API pointing at a pypi.org pass-through.
SP="socket-patch --api-url $MOCK --api-token t --org test-org --patch-server-url $MOCK"
cat > pyproject.toml <<'TOML'
[project]
name = "uvp"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "idna==3.7"]
[project.optional-dependencies]
extra = ["six>=1.15"]
TOML
uv lock && git init -q && git add -A && git commit -qm init
$SP scan --mode hosted --json --yes # success, redirected 1
rm -rf .venv && uv sync --locked # installs the patched wheel (SOCKET_PATCHED == 1)
$SP rollback --json --yes # exit 1, partial_failure, hosted.failed[] = the error above
$SP remove pkg:pypi/six@1.16.0 --json --yes # exit 1, hosted_revert_failed
$SP vendor --json # exit 1, failed redirect_revert_failedThe hosted lock still carries the markers:
requires-dist = [
{ name = "idna", specifier = "==3.7" },
{ name = "six", url = "http://127.0.0.1:8765/patch/pypi/six/1.16.0/…/six-1.16.0-py2.py3-none-any.whl" },
{ name = "six", marker = "extra == 'extra'", url = "http://127.0.0.1:8765/patch/pypi/six/1.16.0/…/six-1.16.0-py2.py3-none-any.whl" },
]
Other pyprojects that reproduce:
dependencies = ["idna==3.7"]with[project.optional-dependencies] a = ["six==1.16.0"],b = ["six>=1.10"]dependencies = ["idna==3.7", "six>=1.10; python_version < \"3.10\"", "six==1.16.0; python_version >= \"3.10\""]
Controls that roll back byte-identically:
- the same specifier in
dependenciesand in an extra with a marker (six==1.16.0andwin = ["six==1.16.0; sys_platform == \"win32\""]) dependencies = ["six==1.16.0"]plus[dependency-groups] dev = ["six"](groups are matched per group)- vendored
vendor→vendor --reverton the failing pyproject
Expected vs actual
- Expected: CLI_CONTRACT.md, "Hosted unwind coverage" (pypi), restores
uv.lock"+ the pairedpyproject.toml" with re-derived hashes. Its refusal list for uv covers non-pure wheels,exclude-newer/no-binary/no-build, registry ambiguity and[[distribution]]locks. It doesn't cover several specifiers for one name, and neither does docs/testing/uv-compatibility.md. If this case can't be unwound, the scan should refuse it up front, the way it already refuses marker forks (redirect_uv_project_unsupported), instead of writing a pin that can't be undone. - Actual: the scan succeeds. Then rollback, remove and the takeover all exit 1 with "not derivable", and
uv.lock/pyproject.tomlkeep the hosted URL.
OS × uv matrix (Linux sandbox; this is pure TOML logic, so no probe run)
| uv | deps + extra (a) | two extras (c) | marker-split deps (e) | same specifier + marker (control) | vendored revert |
|---|---|---|---|---|---|
| 0.4.30 | ❌ | – | n/a (scan refuses: uv locks two six versions) | – | – |
| 0.5.31 | ❌ | – | ❌ | – | – |
| 0.8.17 | ❌ (2 runs; rollback, remove, takeover) | ❌ | ❌ | ✅ | ✅ |
| 0.12.22 | ❌ | – | ❌ | – | – |
First bad
The hosted upstream restore arrived in v5 (#277). Release 4.0.0 reverted from a ledger fragment, so there's nothing earlier to bisect.
Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:801-812:declarations(Declared::Dist)flattensdependenciesand every optional-dependency group into one list, losing which extra (and which marker) each specifier belongs to.crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:951-972:declared_clausesthen requires every declaration of the name to share one clause list and errors otherwise. Matching on the lock entry's ownmarker(the declaration marker,and-ed withextra == '<group>'for optional dependencies) would pick the right declaration.
This is related to #473 (include-group), but the root cause is separate: that one is the Declared::Dev arm skipping table members.
Activity
mikolalysenko commentedon Oct 2, 2026
[agent] Triage: priority:p1 (uv). Plausible on main
045d7ecfrom the cited code:declarations(Declared::Dist)inpatch/redirect/upstream/uv.rsflattensdependenciesand every optional group, anddeclared_clausesrefuses when one name has several clause lists. Related to #473 (include-group) but not the same cause; no open PR covers it.Generated by Claude Code
mikolalysenko commentedon Oct 3, 2026
[agent] Claiming this issue (with #473; shared root cause: the hosted uv unwind matches a lock requirement entry to its pyproject declaration by name over a flattened declaration set, instead of following uv's own lowering: extras/markers per entry,
include-groupexpansion). Branch: agent/fix-uv-unwind-declaration-match. Claim-ID: 2026-10-03T00:20:52Z-f048a3Generated by Claude Code
mikolalysenko commentedon Oct 3, 2026
[agent] Draft PR: #625
Generated by Claude Code
Test uv unwind of extras, markers, include-group
Match uv unwind entries to their declaration
Fix uv hosted unwind declaration matching (#606, #473) (#625)