Skip to content

Preserve Python patches across uv lockfiles - #238

Merged
Mikola Lysenko (mikolalysenko) merged 3 commits into
mainfrom
codex/uv-patch-compatibility
Sep 15, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 3 commits into
mainfrom
codex/uv-patch-compatibility

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

LLM Description written by Codex:gpt-6-astra

Keep hosted and vendored Python patches installed across uv native locks, PEP 751 locks, compiled requirements, and PEP 723 script locks. Hosted native rewrites now replace the registry source and preserve the matching artifact hash and wheel metadata; paired project/script metadata keeps patches installed during ordinary and --locked installs as well as frozen installs.

Hashed requirements retain extras and environment markers while replacing complete hash continuations. Version-specific rewrites preserve other conditional versions. Paired script/project rewrites refuse competing versions or source conflicts before changing either file. Vendored scripts and standalone PEP 751 locks use local patched wheels, and inventory/repair recognizes their references.

Validation

  • Full default workspace test suite: 6,803 passed, 0 failed, with 97 pre-existing ignored tests. All-features library tests passed (3,455 core + 543 CLI), and all-features test compilation passed. Hosted lockfile integration and shared golden tests also passed.
  • cargo clippy --workspace --all-features -- -D warnings passed.
  • Real public socket-patch CLI scans and uv installs across 14 versions, covering every pre-1.0 minor family from 0.0–0.12, plus the 0.6 lock revision boundary: 230 installed-byte comparisons passed, with no mismatches. Native locks, plain/hashed requirements, exports, standalone pylock, and script locks are included where supported.
  • All 99 lock-preservation checks passed. Frozen, locked, and ordinary installs were checked against the published patch's installed-file SHA-256. Vendored frozen checks use a cold cache and offline mode where supported.
  • Real universal hashed requirements preserve an unpatched conditional version byte-for-byte, keep two patched versions' hashes separate, and pass fresh hash-verified installs. Real competing-version project/script probes refuse mutation and preserve both paired files.
  • The complete matrix ran against source commit e11bd419ea9c01b3ecd1aa894b55874718a3ff0a on macOS arm64 with CPython 3.9.6. The compatibility report includes exact commands, binary hashes, per-version results, and sanitized supplemental evidence. This covers every release family, not every patch release or operating system.

uv 0.0 has no native lock format and rejects local wheel paths. Portable native vendoring explicitly requires uv 0.2 or newer; hosted rewriting supports the historical uv 0.1 distribution format. Exports, script locks, and PEP 751 are tested only on versions that implement those formats. Global uv sources cannot safely represent competing conditional versions, so those paired rewrites are explicitly refused.

Companion scanner annotation PR: https://github.com/SocketDev/depscan/pull/26136. Both changes are developed in separate worktrees; the scanner keeps fixtures from the released CLI alongside output from this change.

Keep hosted and vendored Python patches installed across native uv,
PEP 751, requirements, and script lockfiles. Preserve artifact hashes,
paired metadata, and conditional versions; refuse ambiguous rewrites.

Assisted-by: Codex:gpt-6-astra
Publish real CLI and uv installation evidence across fourteen releases,
including frozen, locked, and ordinary installs in both patch modes.
Record unavailable formats and legacy boundaries separately from passes.

Assisted-by: Codex:gpt-6-astra
Resolve the conflict in patch/redirect/mod.rs: main (#237) patched the
inline requirements.txt rewriter to strip trailing inline comments so
the appended --hash is not swallowed into the comment; this branch had
already replaced that body with redirect::requirements::rewrite, which
splits the comment off outside quotes and re-appends it after the hash.
Keep the delegation and update #237's test to expect the preserved
comment (hash still active, user note kept).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit ade011e into main Sep 15, 2026
47 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the codex/uv-patch-compatibility branch September 15, 2026 14:10
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Sep 15, 2026
…for the unwired-revert guard

The unwired-revert guard added after #238 protects a `repair`-reconstructed
pypi ledger entry (no wiring records) from being deleted while a Python
project file still resolves through the vendored wheel. Four gaps remained:

- `keep_artifact` (`rollback/remove --preserve-state`) was still guarded,
  so a preserve-state revert of an unwired entry refused with
  `vendor_wiring_unknown_revert_blocked` although it would have deleted
  nothing. npm skips the guard there because the refusal exists only to
  protect the deletion; the pypi guard now does the same.
- The lock enumeration failed OPEN: a `read_dir` error on the project root
  was read as "no Python locks here", `python_lock_paths` silently dropped
  any lock whose (followed) metadata failed, and the static probe list
  lacked uv.lock and pylock.toml. On a 0311 (execute-only) root, a
  listing-denied ACL or a transient EMFILE, `vendor --revert` deleted the
  referenced wheel and dropped the entry while uv.lock still resolved
  through it. The guard now lists the root itself (lstat only, so a
  symlinked lock with an unreadable target is still probed and fails
  closed on the read), and an unlistable root refuses with its own clause.
- Once the guard cleared, the entry was still dispatched by flavor, which
  made a true orphan unreclaimable forever: flavor `uv` with uv.lock gone
  failed "cannot read uv.lock", and flavor `None` (what `repair` stamps
  for requirements/poetry/pdm/pipenv reconstructions) was unknown to the
  dispatch. An empty-wiring entry that passes the guard now skips the
  dispatch (it has nothing to replay) and proceeds to the uuid-dir removal.
  An UNKNOWN non-None flavor still fails closed: a newer backend's files
  may reference the artifact from a place this guard does not probe.
- The requirements planner writes vendored pins into `-r` includes, but the
  guard probed only the root requirements.txt. `requirements_include_names`
  (new, exported from the core crate for the CLI's repair/orphan sweep)
  walks the same include tree as the planner — FIFO-safe, `Err` when a
  reached include exists but cannot be read — and the guard probes every
  in-root include, failing closed on an unreadable tree.

Verified against real uv 0.9.30 and 0.5.31 through the built CLI: the
refusal on a 0311 root keeps the wheel and `uv sync --frozen --offline`
still installs from it; after `uv remove` the orphan is reclaimed and the
ledger entry dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Sep 16, 2026
…ily (#239)

* Harden uv lockfile patch preservation after #238

Fail-closed revert for unwired Python entries: a ledger entry with no
wiring (the shape `repair` re-synthesizes when state.json is lost) used
to route into the uv / python-lock revert, iterate zero records, report
success, and let the caller delete the vendored wheel and drop the entry
while uv.lock, the pylock, the script, or requirements.txt still
resolved through it. `revert_pypi_opts` now refuses while any Python
project file references the uuid dir, mirroring the npm-family guard.

Inventory: a PEP 723 script lock or PEP 751 lock supplements the
project's poetry.lock / requirements.txt pins instead of hiding them;
uv.lock keeps its exclusive precedence.

Discovery: `python_lock_paths` follows symlinks and skips one unreadable
entry instead of dropping every lock.

Line endings: toml_edit re-emits every newline as LF, so CRLF pylock,
script-lock and pyproject rewrites (and document-restore reverts) now
restore the input's CRLF convention.

Ledger recovery compares the purl name in PEP 503 form like `lookup`.

Hosted pyproject edits render uv's own layout — header-less `[tool]` /
`[tool.uv]` parents and a `[tool.uv.sources]` table after `[project]` —
instead of dotted keys above `[project]`. The PEP 723 script block keeps
fully dotted keys: only those vanish when empty after a parse round
trip, which the out-of-order multi-package document revert relies on.

`redirect_uv_missing_sha256` fires once per dep, not once per lock file;
`repair` prefers uv.lock over an exported pylock when stamping flavor.

Backtest harness: retry the cold-offline root-build failure with network
for any uv version (not just 0.2.37), and pin first, latest, and every
observed behaviour-boundary release of each 0.x family.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Follow each uv `[[distribution]]` lock shape when redirecting

The experimental `[[distribution]]` grammar (uv 0.1.x–0.2.34) went through
three shapes, and the rewriter keyed everything on the table name:

- string sources + sub-table artifacts (`[distribution.sdist]`,
  `[[distribution.wheel]]`) through 0.2.5;
- string sources + inline artifacts (`sdist = {…}`, `wheels = [...]`)
  from 0.2.6 through 0.2.17;
- inline-table sources (`source = { registry = … }`) from 0.2.18.

Emitting the string source into a 0.2.18–0.2.34 lock made uv reject it
("data did not match any variant of untagged enum SourceWire") and
silently ignore the lock: an ordinary `uv sync` still installed the patch
through the pyproject source, but `--frozen` / `--locked` failed. Emitting
`[[distribution.wheel]]` into a 0.2.6–0.2.17 lock left the binary with a
direct URL and no wheel, which it tried to build as a source archive
("Unsupported archive type: …whl"). Decide the source shape from the
entry's own `source` syntax and the artifact shape from its own artifact
keys (falling back to any sibling entry), independently.

Vendored native wiring stays refused for the whole `[[distribution]]` era:
every shape records absolute file paths for local artifacts. Reword the
refusal to say so (it claimed "uv 0.1", but 0.2.34 refuses too).

Add scripts/probe-uv-boundaries.py — the bisection tool behind every
boundary pinned in scripts/backtest-uv.py (lock grammar, source and
artifact shape, lock revision, `uv export`, `uv lock --script`, PEP 751
compilation, bare local wheel paths in requirements) — and pin the
0.2.5/0.2.6 and 0.2.17/0.2.18 pairs in the matrix. Document the exact
boundaries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Vendor into uv 0.2.35/0.2.36 locks that predate [package.metadata]

The first two `[[package]]`-grammar releases wrote no root
`[package.metadata]`; the metadata step refused them with
`pypi_uv_lock_package_missing` although there was nothing to repoint. Skip
the requires-dist rewrite when the root unit has no metadata table at all
(a lock that HAS metadata but lacks the entry is stale and still refuses),
so vendored native wiring covers every `[[package]]` release. Verified
with real `uv sync --frozen` / `--locked` / ordinary installs on 0.2.35,
0.2.36 and 0.2.37.

Pin 0.2.36/0.2.37 as the metadata boundary in the matrix and document the
exact grammar shapes and coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Record the 41-release uv compatibility matrix for source 310b904

Regenerate results.json, binaries.json, and the results section of
docs/testing/uv-compatibility.md from a full run of scripts/backtest-uv.py
against the rebuilt CLI: 41 pinned uv releases (first and latest of every
0.x family plus every observed behaviour boundary), 583 installed-byte
comparisons and 240 lock-preservation checks, zero mismatches and zero
lock changes. Every non-zero exit is a documented uv boundary or the
cold-offline root-build case retried with network.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* utils/fs: export read_regular_to_string and is_symlink helpers

Shared FIFO-safe reader (the shape every ecosystem module re-declared
privately) and an lstat-based symlink probe, exported so the CLI crate's
raw read_to_string sites and the Python lock writers can use them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* uv vendor: classify [tool.uv] dev-dependencies as direct; repoint every metadata entry

classify_dependency scanned project.dependencies, optional-dependencies
and [dependency-groups] but not the legacy `[tool.uv] dev-dependencies`
array, which uv still honours (0.12.15 warns but records it exactly like
`dependency-groups.dev`, in the root unit's [package.metadata.requires-dev]).
Such a target classified Transitive: wire_uv took the override branch,
[package.metadata.requires-dev] kept `specifier = "==…"`, and after a
"successful" vendored scan every uv >= 0.2.37 `uv sync --locked` /
`uv lock --check` exited non-zero while a plain `uv sync` rewrote the lock
(ledger drift) and on 0.2.37/0.4.30 reinstalled the PRISTINE wheel.

rewrite_root_metadata_entries also repointed only the FIRST matching
element per array (a `break` in the requires-dist loop and per group in
requires-dev) although its doc promised ALL: a package declared in both
dependencies and an optional-dependencies extra has two requires-dist
entries (the extra's with `marker = "extra == '…'"`) and uv repoints both,
so the stale second entry kept --locked red.

Fix: extend `declared` with the [tool.uv] dev-dependencies string members
(pep508_name); repoint every matching requires-dist element as its own
edit/wiring record (revert already replays N records per package), and
rebuild each requires-dev group line once with every matching element
rewritten so a group naming the package twice never yields overlapping
spans. Both new fixtures are byte-exact uv 0.12.15 output.

wire_uv now also returns a Vec<VendorWarning> of wiring-time advisories,
threaded into the vendor outcome's warnings by pypi.rs (the channel the
next commit's override-branch advisory uses).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* uv vendor: advise that override-dependencies wiring needs uv >= 0.5.6

The module doc's "claim 8" said [tool.uv.sources] applies to
override-dependencies. Against real binaries that holds only from uv
0.5.6: on 0.2.35–0.5.3 a Transitive target wired through the override
branch is silently reinstalled from the registry by a plain `uv sync`
(the lock still names the vendored path, so `--frozen` installs it, which
is why the matrix looked green). No lock-shape marker separates 0.5.3 from
0.5.6, so an offline refusal is impossible.

Fix: the Transitive branch of wire_uv pushes an advisory VendorWarning
with the stable code `pypi_uv_override_requires_uv_0_5_6` ("pin uv or make
the package a direct dependency") through the outcome's warnings, and the
module doc states the >= 0.5.6 boundary. The Direct branch stays silent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* uv: repoint [manifest] constraints / build-constraints for the redirected package

When the target is named in `[tool.uv] constraint-dependencies` (or
build-constraint-dependencies), uv.lock carries
`[manifest] constraints = [{ name = "urllib3", specifier = "==1.26.18" }]`
(resp. build-constraints). Once the package has a source, uv >= 0.5.6
re-serializes that element as `{ name, path|url }` with the specifier
dropped (uv 0.12.15 ground truth), so after vendoring or a hosted redirect
`uv lock --check` / `uv sync --locked` exited 2 and a plain sync churned
the lock. Neither backend touched those arrays: the vendored surgery only
knew requires-dist / requires-dev / overrides, and the hosted
rewrite_manifest returned early whenever the (script-lock-only)
`requirements` key was absent.

Vendored: rewrite_manifest_constraints repoints every matching element of
both arrays, one whole-line `<key> = […]` fragment per key recorded as the
new wiring kind `uv_lock_manifest_constraints` (unknown kinds are
warned+skipped on revert, so the arm is added to the replace_fragment
group). The records are pushed last so the reverse-order revert restores
them before searching for a requires-dist element that may be
byte-identical. Hosted: rewrite_manifest repoints matching `constraints` /
`build-constraints` elements before the `requirements` early return, and
still invents no `overrides` array for a direct dependency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* uv vendor: keep a CRLF pyproject.toml / uv.lock pair CRLF through wire and revert

wire_uv wrote `doc.to_string()` for pyproject.toml, and toml_edit re-emits
every newline as LF: a pure-CRLF pyproject (git autocrlf on Windows) came
back all-LF — whole-file churn — and revert_uv, which splices fragments
rather than rewriting the file, could never restore it. The lock side had
the same class in three places: rewrite_target_package_unit rebuilt the
target unit with `join("\n")` and appended the reconstructed
[package.metadata] block with "\n\n" (mixed endings inside a CRLF lock);
add_manifest_override created `[manifest]\n…\n\n`, `overrides = […]\n` and
the multi-line `,\n]` tail with LF; and revert_uv removed those fragments
with hardcoded "{new}\n\n" / "{new}\n", so a CRLF lock's created overrides
were left in place with a drift warning.

Fix: render pyproject through preserve_line_endings, record the
uv_override Rewritten old/new array fragments in the file's convention (a
multi-line array renders LF and replace_fragment would miss), detect the
lock's terminator once (newline_of) and build every spliced, appended and
removed lock fragment with it; the target unit's span is trimmed of the
trailing `\r` find_unit_span includes so the rebuilt unit splices back in
front of the same `\r\n`. The regression test CRLF-converts the Direct,
Transitive (created override + created [manifest]), Rewritten-override
(multi-line arrays in both files) and requires-dev fixtures, asserts the
wired pair has no bare LF and equals the LF wiring modulo terminators, and
reverts byte-exactly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* uv: say what a [[distribution]] lock actually cannot do instead of "records absolute paths"

Both refusals (load_uv_project and the vendored ArtifactSource::Path arm of
rewrite_python_lock) claimed uv < 0.2.35 "records absolute file paths".
Against the real 0.1.45–0.2.34 binaries that is not the limitation:
<= 0.2.6 cannot parse a relative path source at all; 0.2.17–0.2.34 install
one under --frozen / plain sync, but `--locked` rejects any non-canonical
spelling, plain sync (0.2.34) and every `uv lock` absolutize it, resolution
is CWD-relative, and 0.2.17/0.2.18 never verify the wheel hash. A user
reading the old text would look for an absolute path to fix rather than
upgrade or switch to a requirements.txt install.

Both messages now read: uv `[[distribution]]` lockfiles (uv < 0.2.35,
experimental `uv lock`) cannot carry a portable local wheel: `--locked`
rejects relative paths and `uv lock`/`uv sync` rewrite them to absolute
ones; upgrade to uv >=0.2.35 for native vendoring, or use a
requirements.txt installation. The vendored refusal gains a pinning test;
the existing hosted assertion (`contains("0.2.35")`) still holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* uv vendor: gate the metadata no-op on the sub-tables too, not the bare header

The 0.2.35/0.2.36 early return in rewrite_root_metadata_entries checked
`unit_text.contains("[package.metadata]")`. The requires-dev sub-table
header `[package.metadata.requires-dev]` does NOT contain that substring
(`.` where the bare header has `]`), so a root unit carrying only the
sub-table would have been treated as "no metadata": the requires-dev
entry kept its specifier, `uv lock --check` / `uv sync --locked` went red,
and nothing refused. Every uv release since 0.2.37 happens to write the
empty bare header first, which is why this never fired.

The no-op now requires the absence of BOTH the bare header and any
`[package.metadata.` sub-table; the later scan already works from the
sub-table header alone, so such a unit is repointed rather than skipped.
Pinned by a test whose root unit has only [package.metadata.requires-dev].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* uv vendor: refuse a symlinked pyproject.toml / uv.lock before any write

atomic_write_bytes_preserving_mode stages the new content next to the
path and renames over it. uv itself writes THROUGH a symlinked uv.lock /
pyproject.toml, but our rename REPLACES the link with a regular file: the
target keeps the pre-vendor bytes, git shows a typechange, and a revert
(which also renames) can never put the link back.

wire_uv now refuses fail-closed before either write, and revert_uv keeps
the artifact and fails, both with `pypi_uv_symlink_unsupported` naming the
offending file (lstat via crate::utils::fs::is_symlink — the link itself,
not its target). The orchestrator sweeps the freshly built wheel on a
wiring refusal, so no `.socket/vendor` residue is left. The module's
private FIFO-safe reader is replaced by the shared
crate::utils::fs::read_regular_to_string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* vendor/pypi: fail-closed listing, include probing and orphan reclaim for the unwired-revert guard

The unwired-revert guard added after #238 protects a `repair`-reconstructed
pypi ledger entry (no wiring records) from being deleted while a Python
project file still resolves through the vendored wheel. Four gaps remained:

- `keep_artifact` (`rollback/remove --preserve-state`) was still guarded,
  so a preserve-state revert of an unwired entry refused with
  `vendor_wiring_unknown_revert_blocked` although it would have deleted
  nothing. npm skips the guard there because the refusal exists only to
  protect the deletion; the pypi guard now does the same.
- The lock enumeration failed OPEN: a `read_dir` error on the project root
  was read as "no Python locks here", `python_lock_paths` silently dropped
  any lock whose (followed) metadata failed, and the static probe list
  lacked uv.lock and pylock.toml. On a 0311 (execute-only) root, a
  listing-denied ACL or a transient EMFILE, `vendor --revert` deleted the
  referenced wheel and dropped the entry while uv.lock still resolved
  through it. The guard now lists the root itself (lstat only, so a
  symlinked lock with an unreadable target is still probed and fails
  closed on the read), and an unlistable root refuses with its own clause.
- Once the guard cleared, the entry was still dispatched by flavor, which
  made a true orphan unreclaimable forever: flavor `uv` with uv.lock gone
  failed "cannot read uv.lock", and flavor `None` (what `repair` stamps
  for requirements/poetry/pdm/pipenv reconstructions) was unknown to the
  dispatch. An empty-wiring entry that passes the guard now skips the
  dispatch (it has nothing to replay) and proceeds to the uuid-dir removal.
  An UNKNOWN non-None flavor still fails closed: a newer backend's files
  may reference the artifact from a place this guard does not probe.
- The requirements planner writes vendored pins into `-r` includes, but the
  guard probed only the root requirements.txt. `requirements_include_names`
  (new, exported from the core crate for the CLI's repair/orphan sweep)
  walks the same include tree as the planner — FIFO-safe, `Err` when a
  reached include exists but cannot be read — and the guard probes every
  in-root include, failing closed on an unreadable tree.

Verified against real uv 0.9.30 and 0.5.31 through the built CLI: the
refusal on a 0311 root keeps the wheel and `uv sync --frozen --offline`
still installs from it; after `uv remove` the orphan is reclaimed and the
ledger entry dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* repair/vendor: FIFO-safe reference scan that also sees `-r` include pins

`repair`'s ledger reconstruction and `vendor --revert`'s orphan sweep share
`scan_vendor_references`, which read every wiring-file candidate with a
plain `tokio::fs::read_to_string`. A FIFO under one of those names — most
easily the paired `<script>.py` derived from a `*.py.lock`, which the lock
lister never stats — blocked forever in open(2) waiting for a writer, so
`repair` never returned. The same raw read sat in `detect_reference_flavor`
(the Python lock loop and the npm lock closure). All three now go through
`read_regular_to_string`, keeping the existing skip-on-error tolerance.

The scan also consulted the root requirements.txt alone, while the
requirements planner writes a vendored pin where the original pin was —
possibly inside a `-r`/`--requirement` include. After a state.json loss
`repair` could not re-adopt such a wheel, and the orphan sweep deleted it
as `vendor_orphan_removed` while `pip install -r` still resolved through
it. The scan now appends the planner's in-root include tree via the new
core `requirements_include_names` (an unreadable tree degrades to the root
file, matching the per-file tolerance), so repair and the sweep see
include-hosted pins.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* python_lock: fix uv version boundaries in comments; pin ≤0.2.5 sdist and sibling-shape rewrites

The `[[distribution]]` shape comments disagreed with the PR's own probe
matrix and docs: the inline-table-source era was labelled "0.2.20 through
0.2.34" (it begins at 0.2.18, the string→inline-table flip is 0.2.17→0.2.18)
and the string-source/inline-artifact era "0.2.14–0.2.17" (it begins at
0.2.6, the sub-table→inline artifact flip is 0.2.5→0.2.6). The test doc
for the latter also attributed bare `[[distribution.dependencies]]` edges
to the whole era, but 0.2.6–0.2.15 still write source-qualified edges;
only 0.2.16–0.2.17 write bare ones. Anyone deciding from the comments
which real uv binary to reproduce a report with would pick the wrong one.

Fix the four sites to the verified boundaries and pin two PR-introduced
behaviours that had no test of their own:

- `legacy_sdist_redirect_emits_sub_table`: a ≤0.2.5 lock (string source,
  `[distribution.sdist]` + `[[distribution.wheel]]` tables) redirected to a
  .tar.gz artifact gets a `[distribution.sdist]` sub-table with url/hash,
  loses the stale `[[distribution.wheel]]`, follows the source-qualified
  edge, and is byte-stable on rerun.
- `artifactless_target_follows_sibling_shape`: an entry with no artifact
  keys takes its grammar from a sibling — `[[distribution.wheel]]`
  siblings yield a sub-table, inline `wheels = […]` siblings yield an
  inline array — and both are byte-stable on rerun.

Both were GREEN on HEAD; they guard the shape selection against regression.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* lock_inventory: document that uv.lock exclusivity is keyed on parse success; pin poetry/script union and corrupt-uv.lock fall-through

The comment above the pypi fallback chain said "uv.lock stays the
EXCLUSIVE project inventory", which reads as presence-keyed. The code keys
exclusivity on `python_lock_inventory` SUCCEEDING: an unparseable uv.lock
contributes nothing and falls through to poetry.lock / requirements.txt
exactly like a package-less poetry.lock does. That is the right behaviour
— presence-keying would hide every requirements pin behind a corrupt lock
and diverge from hosted, which skips the same file with
`redirect_uv_lock_unsupported` and still reads the other pins — but a
reader taking the comment at face value would "fix" it the wrong way.

Reword the comment to say PARSEABLE and spell out why, and pin the two
untested legs of the chain in `python_lock_union_tests`:

- `poetry_lock_unions_with_script_lock_and_hides_requirements`: with no
  uv.lock, poetry.lock is the tool lock (hides requirements.txt) while a
  `tool.py.lock` still unions with it.
- `unparseable_uv_lock_falls_through_to_requirements`: garbage-TOML
  uv.lock + requirements.txt yields the requirements pin.

Both were GREEN on HEAD.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* python_script: scope the SourcesLayout revert claim to direct deps; pin where toml_edit places [tool.uv.sources]

The SourcesLayout doc claimed an "out-of-order multi-package document
revert converges" for the dotted script layout. That holds for DIRECT
deps, each of which owns one `sources.<name>` key; TRANSITIVE pairs share
a single `override-dependencies` array and `restore_value`'s array branch
flags drift as soon as the entries stop lining up, so their revert is
order-dependent whatever the layout. It also promised "a real
`[tool.uv.sources]` table AFTER `[project]`", but toml_edit places a new
table right after the last pre-existing table it visits in depth-first
key order: after a trailing `[build-system]`, right after an existing
`[tool.uv]`, and — with `[tool.ruff]` as the only tool table ahead of
`[project]` — inside the tool block BEFORE `[project]`. Both statements
would mislead whoever next touches revert or placement.

Scope the doc to direct deps, describe the placement rule as observed, and
pin the renderings with idempotency (a second rewrite returns None) after
every assertion, including the two pre-existing rendering tests:

- trailing `[build-system]` after `[project]`, LF and CRLF;
- `[tool]` with `uv = { … }` as an inline table: the source lands as a
  dotted `sources.alpha` key inside the inline table, parses, settles;
- `[tool.ruff]` before `[project]` (direct + transitive) and `[tool.uv]`
  followed by `[tool.ruff]`;
- upgrade path from an older CLI's root-body dotted
  `tool.uv.sources.other = {…}`: the new source extends the dotted table
  instead of adding a conflicting header;
- PEP 723 block with a user-authored `# [tool.uv.sources]` / `# [tool.uv]`
  header: our insertion joins the table, the extracted block parses, and
  every line is re-commented.

All GREEN on HEAD; no rendering bug surfaced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* pypi_lock: read symlinked locks like their targets, refuse to write through them

Since Python lock discovery started following symlinks (#239), `read_file`'s
lstat-based `symlink_metadata().is_file()` pre-check rejected every linked
pylock.toml / *.py.lock with `pypi_lock_read_failed: … is not a regular
file`. `contains_target` and `load_python_locks` bubble the FIRST unreadable
sibling, so a stray non-referencing `pylock.dev.toml` link beside a REGULAR
pylock.toml blocked vendoring of the whole project — on main the link was
simply invisible.

Reading through a link is safe (`open_regular_file`'s fstat already accepts a
link to a regular file and still rejects FIFOs/directories), so `read_file`
now delegates to the shared `read_regular_to_string`. Writing through one is
not: every writer here stages next to the path and renames over it, which
replaces the link with a detached regular copy — the target goes stale (uv
itself writes THROUGH the link) and a later revert restores bytes but never
the link (git: 120000→100644 typechange). The hosted replay side already
refuses linked files fail-closed; `wire_python_locks` and
`revert_python_locks` now check `is_symlink` on every file they are about to
write (the lock AND the paired PEP 723 script) BEFORE the first write and
refuse with `pypi_lock_symlink_unsupported` naming the file. A refused revert
fails outright so the artifact and ledger entry stay for a retry.

Tests (RED-first for the four symlink cases): stray sibling link no longer
blocks the regular lock; dangling link is ignored by discovery; symlinked lock
and symlinked script are refused before any write (link intact, target
byte-identical, no .socket/); the revert twin fails and keeps the vendored
bytes. Also pins `restore_document`'s CRLF re-application (LF ledger + CRLF
live, the mirror, and the two-package out-of-order CRLF path that does not
short-circuit) and documents the known order-dependence of reverting two
TRANSITIVE script deps sharing one override-dependencies array.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* hosted: refuse symlinked rewrite targets before the ledger; FIFO-safe candidate reads

The hosted write loop renames a staged copy over every `rewrite.files` key.
Since discovery follows links, a symlinked uv.lock / pylock*.toml / *.py.lock
/ pyproject.toml (and, all along, a symlinked package-lock.json) was silently
converted into a detached regular copy: the shared target stayed unpatched
(uv writes THROUGH the link), and `--revert` could restore bytes but never the
link. Reproduced against uv 0.12.15. The revert side (replay.rs) already
refuses linked files; the write side now matches: before `save_redirect_state`
and before any file write, every planned file is checked with the new
`utils::fs::first_symlink`, and a hit fails the WHOLE rewrite (hosted
rewrites are transactional) with exit 1, stable code
`redirect_symlinked_file_unsupported` naming the file, `errorCode` in the
JSON envelope, no ledger and no writes. Applies to every ecosystem and to dry
runs, so a dry run predicts the refusal rather than a rewrite that will never
happen.

Separately, six candidate reads (REDIRECT_CANDIDATE_FILES, the Python locks
and their paired scripts, the rush common/subspace locks, and the sync
pnpm-workspace.yaml trust probe) used a plain `read_to_string`: a FIFO planted
under any of those names wedged `scan --mode hosted` / `get --mode hosted`
forever in open(2) waiting for a writer (reproduced). They now go through
`read_regular_to_string` (non-blocking open + fstat), plus a new blocking twin
`read_regular_to_string_sync` for the sync probe that keeps its
NotFound/PermissionDenied/InvalidData classification. A non-regular file reads
as unreadable and is skipped like a missing one.

Tests (RED-first): `hosted_refuses_symlinked_lock_before_ledger_write` (real
0.12.15 uv.lock linked into a shared dir, wheel served by wiremock: exit 1,
link intact, pyproject untouched, no .socket/), the package-lock.json twin
with the human-output check, a regular-file control that redirects, and
`hosted_scan_returns_with_fifo_candidate` (mkfifo pyproject.toml beside a
real uv.lock, 90 s deadline with FIFO release on timeout — wedged before the
fix, returns in ~1 s after). fs.rs unit tests cover the sync reader's error
kinds and FIFO fail-fast and `first_symlink`'s ordering/dangling semantics.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* backtest-uv: variant lane, stdout export, and a doc-table renderer

The real-uv matrix's only native-project fixture is
`dependencies = ["urllib3==1.26.18"]`, which never reaches the
`[tool.uv] dev-dependencies` / PEP 735 requires-dev paths, a duplicate
requires-dist entry, a `[manifest]` constraints entry, or the transitive
override branch — exactly the shapes where `uv sync --locked` was found
to exit 2 after a "successful" scan. Add `variant_matrix`, run on every
`[[package]]`-grammar binary: five pyproject shapes (tool-uv-dev,
dependency-groups, extras-duplicate, constraints, transitive) locked
fresh, scanned hosted and vendored, then installed into a fresh venv
with --frozen, --locked (where listed) and a plain `uv sync`, recording
exit code, lockUnchanged and the installed response.py hash. A plain
sync that rewrites the lock is recorded, not raised: on uv < 0.5.6 the
transitive fixture is expected to reinstall the registry wheel. Fixtures
the binary cannot lock (no [dependency-groups] before 0.4.27, no
[manifest] constraints on older uv) record formatSupported: false. The
transitive cut-off lives in `[tool.uv] exclude-newer` rather than on the
`uv lock` command line: a CLI flag is stored under `[options]` and its
absence on sync makes uv re-resolve ("removal of global exclude newer"),
failing --locked for a reason unrelated to the patch.

Against the pre-fix CLI on 0.12.15 and 0.2.37 the lane is red where the
review said it would be: vendored tool-uv-dev and extras-duplicate and
both modes of constraints fail --locked on 0.12.15; on 0.2.37
tool-uv-dev and transitive fail --locked and plain sync reinstalls the
pristine wheel.

The export lane passed `uv export --output-file`, which only exists from
0.4.7, so the pinned export-boundary release 0.4.1 recorded four exit-2
rows and the doc showed "— / —" for a release whose export installs the
patch. Read the export from stdout and write the file only on a
successful exit (an empty file would pass for a lock in format_matrix);
0.4.1 now records Pass / Pass for the requirements export.

write_summary carries the variant observations and gives each version
row a `variants` verdict, plus `pythonVersion`. `--render-doc-table
<results.json>` prints the generated section of
docs/testing/uv-compatibility.md (run header, per-release table,
project-variant table) so the doc is regenerated after a run instead of
hand-edited; rendering the committed results.json reproduces the current
41 rows byte-for-byte.

probe-uv-boundaries.py separates "the `uv lock` subcommand exists"
(0.1.42) from "it writes a lock" (0.1.45; 0.1.42–0.1.44 panic "not yet
implemented"), reports the panic line, and checks whether `uv export`
accepts `--output-file` (0.4.7). Verified on real 0.1.41/0.1.44/0.1.45
and 0.4.6/0.4.7 binaries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: correct the uv boundaries and describe the #239 hardening

docs/testing/uv-compatibility.md carried three inaccuracies the real
binaries contradict: `[[distribution]]` locks start at 0.1.45, not
"0.1.x" (the `uv lock` subcommand appears in 0.1.42, panics through
0.1.44 and first writes a lock at 0.1.45); the root-build note blamed
"uv 0.2.x and 0.3.0" when the cold --offline failure spans 0.2.35–0.3.5,
0.3.3 adds `--no-install-project` (which the harness passes), and 0.4.0
stops building a build-system-less root; and the vendored refusal for
`[[distribution]]` locks claimed uv "records absolute file paths" when
the real reasons are that a relative path source is unparseable through
0.2.6 and, on 0.2.17–0.2.34, rejected by --locked and absolutized by
`uv lock` / plain `uv sync` with CWD-relative resolution and unverified
hashes. Fix all three, add the `uv export --output-file` (0.4.7)
boundary, define "lock-preservation check" precisely (every install
attempt against a patched lock, failed installs included, with the
--locked rows as the ones that measure preservation), and stop calling
every non-zero exit a documented uv boundary: the 0.4.1 export rows were
a harness gap.

Document the hardening landing in #239: [tool.uv] dev-dependencies as a
direct dependency with every duplicate requires-dist/requires-dev entry
repointed, [manifest] constraints repointed, the
pypi_uv_override_requires_uv_0_5_6 advisory (uv applies sources to
overrides only from 0.5.6), symlinked project files refused before any
write in both modes, vendored requirements' hash-enforcement boundaries
(--require-hashes from 0.1.32, default from 0.5.x), and the
CRLF-preserving vendored uv backend. Describe the new project-variant
lane and `--render-doc-table`, and fence the results section with
GENERATED markers so the merger regenerates it after the full rerun;
the recorded numbers are unchanged and the rerun replaces the
variant-table placeholder.

docs/ecosystems.md and CHANGELOG.md: hosted uv coverage is requirements
from 0.0.5 and native uv.lock from 0.1.45, not "every release since
0.1"; symlinked locks are discovered and refused before any write; the
CRLF bullet covers the vendored backend; new bullets for the --locked
fixes, the constraints repoint, the override advisory, the reworded
[[distribution]] refusal, the unwired-revert guard fixes and the
FIFO-safe CLI reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* uv: count [tool.uv] dev-dependencies as direct for hosted sources; bracket the uv lock boundary

Hosted twin of the vendored classifier fix: rewrite_project_metadata decided
"direct" from project.dependencies / optional-dependencies /
dependency-groups only, so a package declared in the legacy [tool.uv]
dev-dependencies array got an override-dependencies entry plus a trailing
[manifest] overrides section it never needed — and uv < 0.5.6 does not apply
[tool.uv.sources] to overrides at all. uv records that array under
[package.metadata.requires-dev], which complete_python_lock_metadata already
repoints, so the source alone carries the redirect.

Pin uv 0.1.44 in the backtest matrix so the `uv lock` boundary (first lock
written by 0.1.45; the subcommand panics through 0.1.44) is bracketed like
every other one, and refresh the stale CRLF premise in the toml_surgery
removal-helper test now that the uv wire renders in the file's convention.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* uv vendor: advise that the [manifest] constraints repoint needs uv >= 0.5.6

The 42-release matrix's new constraints variant showed the boundary the
override branch already had: uv 0.2.37–0.5.3 serialize [manifest]
constraints as { name, specifier } regardless of sources, so the repointed
entry (the shape uv >= 0.5.6 writes and requires) makes `uv sync --locked`
fail there, while --frozen and a plain sync still install the patch (the
plain sync rewrites the entry back). No lock marker separates 0.5.3 from
0.5.6, so emit the advisory pypi_uv_constraints_require_uv_0_5_6 next to
the repoint instead of refusing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Record the 42-release uv compatibility matrix for source 17d0dcb

Regenerate results.json, binaries.json and the generated results section of
docs/testing/uv-compatibility.md from a full scripts/backtest-uv.py run
against the rebuilt CLI at 17d0dcb: 42 pinned uv releases (0.1.44 added to
bracket the `uv lock` boundary), 1403 installed-byte comparisons and 1086
lock-preservation checks including the new project-variant lane
([tool.uv] dev-dependencies, [dependency-groups], extras duplicate,
constraint-dependencies, transitive) on every [[package]] binary.

Every mismatch and lock change is a documented uv boundary below 0.5.6:
the 18 mismatches are the transitive variant's plain `uv sync` on
0.2.35–0.5.0 (sources not applied to override-dependencies), the 32 lock
changes / non-zero --locked rows are those plus the constraints variant on
0.2.37–0.5.0 ({ name, specifier } serialization regardless of sources);
the patch still installed in every constraints row. From 0.5.16 every
shape passes in both modes with the lock byte-unchanged. The 0.4.1 export
lane now records real installs (stdout export).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* utils/fs: accept Windows' PermissionDenied for a directory in the sync-reader test

CreateFileW refuses to open a directory with ERROR_ACCESS_DENIED before the
fstat regular-file check can classify it as InvalidInput; the callers skip
either error kind, so the test now accepts both off Unix. Windows CI leg
on 18d4046 was the only failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants