From 24a64dc3974886dfbf74bf7fe3a88ee9b1979c8d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:24:37 +0000 Subject: [PATCH 1/4] Start fix for #638 Assisted-by: Claude Code:claude-opus-5-5 From 693b1a8712c15e2586e9a183c60b02f27f7916b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:33:10 +0000 Subject: [PATCH 2/4] Warn when a PyPI hosted scan finds no lockfile A hosted scan of a Python project with no root requirements.txt, tool lock (uv.lock, poetry.lock, pdm.lock, Pipfile.lock), PEP 751 or PEP 723 lock, or Hatch configuration left its PyPI patches unpinned while `--json` showed an empty redirect.warnings list. Every other ecosystem reports this case (redirect_npm_no_lockfile and friends). The PyPI rewriter lane now adds one redirect_pypi_no_lockfile warning per run in that case. It names the files looked for, notes that other requirements files such as requirements-dev.txt are not read, and tells a pyproject.toml project to generate and commit its lock. Refs #638 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 45 +++++ .../patch/redirect/pypi_no_lockfile_tests.rs | 160 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 crates/socket-patch-core/src/patch/redirect/pypi_no_lockfile_tests.rs diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ccb8bb226..d9137f2c8 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -92,6 +92,8 @@ mod platform_wheel_tests; #[cfg(test)] mod pnpm_equivalence_tests; mod poetry; +#[cfg(test)] +mod pypi_no_lockfile_tests; mod pypi_takeover; pub use pypi_takeover::preflight_pypi_takeover; #[cfg(test)] @@ -756,6 +758,7 @@ fn rewriter_groups<'a>( rewrite_hatch(files, overrides, result); rewrite_uv_lock(files, overrides, artifact_metadata, result); poetry::rewrite_poetry(files, overrides, result); + warn_no_pypi_wiring(files, overrides, result); }), Box::new(move |result| rewrite_cargo_with(files, overrides, cargo_outer, result)), Box::new(move |result| rewrite_composer_lock(files, overrides, result)), @@ -1021,6 +1024,48 @@ fn merge_group_outputs(prefix: &RewriteResult, outs: Vec) -> Opti Some(result) } +/// #638: the PyPI twin of `redirect_npm_no_lockfile` / +/// `redirect_composer_no_lockfile`. A pypi patch reached the PyPI lane, but +/// the project has no file any PyPI rewriter reads (a root +/// `requirements.txt`, a tool lock, a PEP 751 / PEP 723 lock, or a Hatch +/// configuration), so nothing can pin it. Said once per run, naming what +/// was looked for; the per-patch `redirect_unconfirmed` row still follows. +/// A file that IS present owns its deps' diagnostics instead. +fn warn_no_pypi_wiring( + files: &BTreeMap, + overrides: &[DepOverride], + result: &mut RewriteResult, +) { + if !overrides.iter().any(|dep| dep.ecosystem == "pypi") { + return; + } + let wired = files.keys().any(|key| { + let name = key.rsplit('/').next().unwrap_or(key); + key == crate::formats::governing_locks::PYPI_REQUIREMENTS + || crate::formats::governing_locks::PYPI_TOOL_LOCKS.contains(&name) + || crate::utils::python_lock::is_python_lock_name(name) + }); + if wired || crate::utils::hatch::is_hatch(files) { + return; + } + let remedy = if files.contains_key("pyproject.toml") { + "pyproject.toml declares the dependencies but no lock is present; \ + generate and commit the lock its tool installs from (`uv lock`, \ + `poetry lock`, `pdm lock`) and re-run" + } else { + "pin the package in a root requirements.txt or commit the project's \ + lock and re-run" + }; + result.warnings.push(RewriteWarning { + code: "redirect_pypi_no_lockfile".into(), + detail: format!( + "no requirements.txt, uv.lock, poetry.lock, pdm.lock, Pipfile.lock or \ + pylock.toml present at the project root (hosted mode does not read \ + other requirements files such as requirements-dev.txt); {remedy}" + ), + }); +} + fn rewrite_hatch( files: &BTreeMap, overrides: &[DepOverride], diff --git a/crates/socket-patch-core/src/patch/redirect/pypi_no_lockfile_tests.rs b/crates/socket-patch-core/src/patch/redirect/pypi_no_lockfile_tests.rs new file mode 100644 index 000000000..8480435ff --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/pypi_no_lockfile_tests.rs @@ -0,0 +1,160 @@ +//! #638: a granted pypi patch in a project with no file a PyPI rewriter +//! reads (no root requirements.txt, uv.lock, poetry.lock, pdm.lock, +//! Pipfile.lock or PEP 751 / PEP 723 lock, and no Hatch configuration) is +//! reported once per run as `redirect_pypi_no_lockfile`, the PyPI twin of +//! `redirect_npm_no_lockfile` / `redirect_composer_no_lockfile`, so a +//! `--json` consumer sees why nothing was pinned. + +use super::*; + +const CODE: &str = "redirect_pypi_no_lockfile"; + +fn six(uuid: &str) -> DepOverride { + DepOverride { + ecosystem: "pypi".into(), + name: "six".into(), + namespace: None, + version: "1.16.0".into(), + token: "11111111-1111-4111-8111-111111111111".into(), + patch_uuid: uuid.into(), + artifact_url: format!( + "https://patch.socket.dev/patch/pypi/six/1.16.0/11111111-1111-4111-8111-111111111111/{uuid}/six-1.16.0-py2.py3-none-any.whl" + ), + registry_override: None, + integrity: Integrity { + sha256: Some("c".repeat(64)), + ..Default::default() + }, + } +} + +fn files(entries: &[(&str, &str)]) -> BTreeMap { + entries + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn codes(result: &RewriteResult) -> Vec<&str> { + result.warnings.iter().map(|w| w.code.as_str()).collect() +} + +fn no_lockfile_warnings(result: &RewriteResult) -> Vec<&RewriteWarning> { + result.warnings.iter().filter(|w| w.code == CODE).collect() +} + +const UV_PROJECT: &str = "[project]\nname = \"app\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\", \"idna==3.7\"]\n"; + +/// The issue's matrix: only `requirements-dev.txt`, only +/// `requirements/base.txt`, no Python file at all, and a uv project whose +/// `uv.lock` is not committed. Each warns exactly once, however many pypi +/// patches are granted, and the detail names what was looked for. +#[test] +fn every_lockless_layout_warns_once() { + let dev = "six==1.16.0\nidna==3.7\n"; + let layouts: [(&str, BTreeMap); 4] = [ + ( + "requirements-dev.txt only", + files(&[("requirements-dev.txt", dev)]), + ), + ( + "requirements/base.txt only", + files(&[("requirements/base.txt", dev)]), + ), + ("no python file", BTreeMap::new()), + ( + "pyproject without uv.lock", + files(&[("pyproject.toml", UV_PROJECT)]), + ), + ]; + let overrides = [ + six("aaaaaaaa-0000-4000-8000-000000000638"), + six("bbbbbbbb-0000-4000-8000-000000000638"), + ]; + for (layout, files) in layouts { + let result = rewrite_registry_redirect(&files, &overrides); + assert!(result.files.is_empty(), "{layout}: {:?}", result.files); + let warnings = no_lockfile_warnings(&result); + assert_eq!(warnings.len(), 1, "{layout}: {:?}", codes(&result)); + let detail = &warnings[0].detail; + for looked_for in [ + "requirements.txt", + "uv.lock", + "poetry.lock", + "pdm.lock", + "Pipfile.lock", + "pylock.toml", + ] { + assert!(detail.contains(looked_for), "{layout}: {detail}"); + } + } +} + +/// A uv project without its lock is told how to get one; a project without +/// a pyproject.toml is not. +#[test] +fn pyproject_without_a_lock_names_the_lock_step() { + let overrides = [six("aaaaaaaa-0000-4000-8000-000000000638")]; + let result = rewrite_registry_redirect(&files(&[("pyproject.toml", UV_PROJECT)]), &overrides); + let detail = &no_lockfile_warnings(&result)[0].detail; + assert!(detail.contains("pyproject.toml"), "{detail}"); + assert!(detail.contains("uv lock"), "{detail}"); + + let result = rewrite_registry_redirect(&BTreeMap::new(), &overrides); + let detail = &no_lockfile_warnings(&result)[0].detail; + assert!(!detail.contains("uv lock"), "{detail}"); +} + +/// Any file a PyPI rewriter reads owns the diagnostics for its deps: no +/// run-level warning beside it, even when it does not pin the package +/// (that is `redirect_requirements_entry_not_found` and friends). +#[test] +fn a_python_lock_or_root_requirements_suppresses_the_warning() { + let overrides = [six("aaaaaaaa-0000-4000-8000-000000000638")]; + let hatch = "[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n"; + let present = [ + ("requirements.txt", "idna==3.7\n"), + ("uv.lock", "version = 1\n"), + ("poetry.lock", "# empty\n"), + ("pdm.lock", "# empty\n"), + ("Pipfile.lock", "{}"), + ("pylock.toml", "lock-version = \"1.0\"\n"), + ("pylock.dev.toml", "lock-version = \"1.0\"\n"), + ("tool.py.lock", "version = 1\n"), + ("hatch.toml", "[envs.default]\n"), + ("pyproject.toml", hatch), + ]; + for (name, text) in present { + let result = rewrite_registry_redirect(&files(&[(name, text)]), &overrides); + assert!( + no_lockfile_warnings(&result).is_empty(), + "{name}: {:?}", + result.warnings + ); + } +} + +/// No pypi patch granted, or every one withheld before the PyPI lane (a +/// platform wheel, #701): nothing to warn about. +#[test] +fn no_warning_without_a_pypi_patch_in_the_lane() { + let result = rewrite_registry_redirect(&BTreeMap::new(), &[]); + assert!(no_lockfile_warnings(&result).is_empty()); + + let mut wheel = six("aaaaaaaa-0000-4000-8000-000000000638"); + wheel.artifact_url = wheel.artifact_url.replace( + "six-1.16.0-py2.py3-none-any.whl", + "six-1.16.0-cp311-cp311-manylinux_2_17_x86_64.whl", + ); + let result = rewrite_registry_redirect(&BTreeMap::new(), &[wheel]); + assert!( + codes(&result).contains(&"redirect_pypi_platform_wheel"), + "{:?}", + codes(&result) + ); + assert!( + no_lockfile_warnings(&result).is_empty(), + "{:?}", + codes(&result) + ); +} From 2512759de1a03a1a3438c77fea7553994bea4eb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:35:01 +0000 Subject: [PATCH 3/4] Test and document redirect_pypi_no_lockfile Adds a hosted scan test for each project layout in #638 that has no Python lock: only requirements-dev.txt, only requirements/base.txt, no requirements file, and a pyproject.toml without uv.lock. Each run must report the warning once, keep the redirect_unconfirmed row, and write nothing. CLI_CONTRACT.md now lists the new warning code. Refs #638 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/mode_migration_pypi.rs | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 47abe95a3..d373b793f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -323,7 +323,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place (a re-run restores the zip line `go mod tidy` drops while no package of the module is loaded, so a later import still builds), never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum has no zip (`h1:` of the module tree) line for at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written); with a `go.work` in effect the build graph is the workspace's (every `use` member's go.mod, go.sum and `go.work.sum`), so a member raising the module refuses the pin with `redirect_golang_version_mismatch`, and a `go.work` that overrides, ignores or rejects the replace refuses it with `redirect_golang_workspace_conflict` before anything is written. Run in the directory holding the `go.work`, a module the workspace shares (no root `go.mod`, or the root module is a `use` member and another member lists it or `go.work` already holds the Socket replace) is pinned in `go.work` and its two sum lines go into `go.work.sum`, the members' files untouched; a member's own `replace` of it, which the go.work pin would override, refuses it (`redirect_golang_workspace_conflict`); a `/go.mod`-only line doesn't count, since go writes one for every version MVS reads (#509). In a go < 1.17 graph, where `require` holds only lower bounds, the build selects the highest of the required version and go.sum's zip-line versions, and a patch for a lower version warns `redirect_golang_version_mismatch` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A vendored Composer package is taken over the same way: its `composer.lock` entry is restored to the recorded pre-vendor entry, its ledger entry and committed copy are removed, and it is redirected in the same commit, so the lock matches a direct hosted scan and `rollback` can restore it from packagist. Without the revert the rewriter edited the vendored entry in place and kept its patch-uuid `dist.reference`, which `rollback` refused. A vendored Composer entry the takeover cannot revert (its vendored ledger entry is missing) is left vendored with `redirect_composer_vendored_entry`, never repointed in place. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (a top-level `warnings[]` entry, and the `errorCode` of the package's `skipped` event). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is a `skipped` event whose `errorCode` is the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `verified` events and warnings are the wet run's `applied` events and warnings. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` before its first wet write (the staged takeover reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON prints the envelope with `status: "error"` and `error: {code: "lock_held" | "lock_io", message}` (no `redirect` payload: nothing was rewritten). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). Under `--json` (v5.0, MAJOR) the outcome is recorded into the scan envelope: one event per selected patch, sorted by purl, each with `details.mode: "hosted"` — `applied` for a confirmed pin (`verified` under `--dry-run`), `skipped` with the skip reason as `errorCode` (and its detail as `reason`), or `skipped` / `redirect_unconfirmed` for a patch that was granted but no lockfile entry pinning it could be rewritten (the human output's `Not hosted : …` line) — plus a `redirect: {mode: "hosted", rewrittenFiles}` payload (the files rewritten, or that would be). A `skipped` event does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip), `redirect_cargo_dep_overridden` (the crate does not resolve to crates.io because the user overrides it — its one `Cargo.lock` block carries a git / other-registry `source`, as a `[patch]` git or registry override in the root manifest or any cargo config leaves it, or has no `source` while the root `Cargo.toml` / project `.cargo/config*` holds a `[patch]` path entry for it; with no `Cargo.lock`, the same `[patch.crates-io]` / `[patch."https://github.com/rust-lang/crates.io-index"]` entry for the crate, by key or `package = "…"`, in the root `Cargo.toml` or the project `.cargo/config*` — transactional skip, nothing rewritten: a Socket pin would silently replace the user's fork and the then-unused `[patch]` entry breaks `cargo --locked`). Also additive: `redirect_cargo_directory_source` (#455: a cargo config replaces crates.io with an on-disk source through its `[source.crates-io] replace-with` chain — a directory source, as a `cargo vendor` project has, or a `cargo local-registry` — so the project builds every crates.io crate offline from it and a Socket registry pin would break `cargo build --frozen --offline`. The configs read are the ones cargo reads for a build in the scanned directory: the project's own `.cargo/config*`, every parent directory's (a monorepo root) and `$CARGO_HOME`'s. Every cargo dependency is skipped with nothing written, one warning each naming the crate and the config that set the replacement, and the detail points at agent mode, which patches the copy cargo builds in place. A replacement with a registry mirror is not an offline source and is still redirected). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place (a re-run restores the zip line `go mod tidy` drops while no package of the module is loaded, so a later import still builds), never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum has no zip (`h1:` of the module tree) line for at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written); with a `go.work` in effect the build graph is the workspace's (every `use` member's go.mod, go.sum and `go.work.sum`), so a member raising the module refuses the pin with `redirect_golang_version_mismatch`, and a `go.work` that overrides, ignores or rejects the replace refuses it with `redirect_golang_workspace_conflict` before anything is written. Run in the directory holding the `go.work`, a module the workspace shares (no root `go.mod`, or the root module is a `use` member and another member lists it or `go.work` already holds the Socket replace) is pinned in `go.work` and its two sum lines go into `go.work.sum`, the members' files untouched; a member's own `replace` of it, which the go.work pin would override, refuses it (`redirect_golang_workspace_conflict`); a `/go.mod`-only line doesn't count, since go writes one for every version MVS reads (#509). In a go < 1.17 graph, where `require` holds only lower bounds, the build selects the highest of the required version and go.sum's zip-line versions, and a patch for a lower version warns `redirect_golang_version_mismatch` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A vendored Composer package is taken over the same way: its `composer.lock` entry is restored to the recorded pre-vendor entry, its ledger entry and committed copy are removed, and it is redirected in the same commit, so the lock matches a direct hosted scan and `rollback` can restore it from packagist. Without the revert the rewriter edited the vendored entry in place and kept its patch-uuid `dist.reference`, which `rollback` refused. A vendored Composer entry the takeover cannot revert (its vendored ledger entry is missing) is left vendored with `redirect_composer_vendored_entry`, never repointed in place. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (a top-level `warnings[]` entry, and the `errorCode` of the package's `skipped` event). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is a `skipped` event whose `errorCode` is the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `verified` events and warnings are the wet run's `applied` events and warnings. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` before its first wet write (the staged takeover reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON prints the envelope with `status: "error"` and `error: {code: "lock_held" | "lock_io", message}` (no `redirect` payload: nothing was rewritten). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). Under `--json` (v5.0, MAJOR) the outcome is recorded into the scan envelope: one event per selected patch, sorted by purl, each with `details.mode: "hosted"` — `applied` for a confirmed pin (`verified` under `--dry-run`), `skipped` with the skip reason as `errorCode` (and its detail as `reason`), or `skipped` / `redirect_unconfirmed` for a patch that was granted but no lockfile entry pinning it could be rewritten (the human output's `Not hosted : …` line) — plus a `redirect: {mode: "hosted", rewrittenFiles}` payload (the files rewritten, or that would be). A `skipped` event does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_pypi_no_lockfile` (pypi: a granted patch reached the PyPI rewriters but the project has no root `requirements.txt`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, PEP 751 / PEP 723 lock or Hatch configuration — once per run, beside each patch's `redirect_unconfirmed` row; #638), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip), `redirect_cargo_dep_overridden` (the crate does not resolve to crates.io because the user overrides it — its one `Cargo.lock` block carries a git / other-registry `source`, as a `[patch]` git or registry override in the root manifest or any cargo config leaves it, or has no `source` while the root `Cargo.toml` / project `.cargo/config*` holds a `[patch]` path entry for it; with no `Cargo.lock`, the same `[patch.crates-io]` / `[patch."https://github.com/rust-lang/crates.io-index"]` entry for the crate, by key or `package = "…"`, in the root `Cargo.toml` or the project `.cargo/config*` — transactional skip, nothing rewritten: a Socket pin would silently replace the user's fork and the then-unused `[patch]` entry breaks `cargo --locked`). Also additive: `redirect_cargo_directory_source` (#455: a cargo config replaces crates.io with an on-disk source through its `[source.crates-io] replace-with` chain — a directory source, as a `cargo vendor` project has, or a `cargo local-registry` — so the project builds every crates.io crate offline from it and a Socket registry pin would break `cargo build --frozen --offline`. The configs read are the ones cargo reads for a build in the scanned directory: the project's own `.cargo/config*`, every parent directory's (a monorepo root) and `$CARGO_HOME`'s. Every cargo dependency is skipped with nothing written, one warning each naming the crate and the config that set the replacement, and the detail points at agent mode, which patches the copy cargo builds in place. A replacement with a registry mirror is not an offline source and is still redirected). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A pin whose own pnpm or yarn lock also installs an unpatched copy of the same `name@version` (a pnpm `file:` directory or tarball dependency; a yarn `file:` / URL / berry `portal:` / `link:` dependency declared under another name) is written too, with a `redirect_copy_unpatched` warning naming that entry, and is not attested. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index afac1baab..aaaecc92c 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -4183,3 +4183,70 @@ async fn hatch_vendored_rerun_refuses_after_relocation_to_unsafe_root() { wired ); } + +/// #638: a pip project whose venv holds a patched package but which has no +/// root requirements.txt or Python lock (its pins live in +/// requirements-dev.txt or requirements/base.txt, it has no requirements +/// file at all, or it is a uv project whose uv.lock is not committed). The +/// hosted `--json` envelope used to carry no warning at all; it now says +/// `redirect_pypi_no_lockfile` once, beside the per-purl +/// `redirect_unconfirmed` row, and writes nothing. The exit code stays 0 +/// (the hosted exit policy is #704). +#[tokio::test] +async fn hosted_scan_without_a_python_lock_warns_no_lockfile() { + let pins = "six==1.16.0\nidna==3.7\n"; + let project_toml = "[project]\nname = \"app\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\", \"idna==3.7\"]\n"; + for (layout, files) in [ + ("requirements-dev.txt", vec![("requirements-dev.txt", pins)]), + ( + "requirements/base.txt", + vec![("requirements/base.txt", pins)], + ), + ("no requirements file", vec![]), + ( + "pyproject.toml without uv.lock", + vec![("pyproject.toml", project_toml)], + ), + ] { + let (_tmp, root) = project(); + for (rel, text) in &files { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + let venv = root.join("../installed-venv"); + venv_installed_from_patch_a(&venv); + let before = tree(&root); + + let server = MockServer::start().await; + mount_hosted_api(&server, true).await; + let uri = server.uri(); + let (code, env) = run_cli( + &root, + &hosted_scan_args(&uri), + &[("VIRTUAL_ENV", venv.to_str().unwrap())], + ); + assert_eq!(code, 0, "{layout}: {env:#}"); + assert_eq!(hosted_pinned(&env), 0, "{layout}: {env:#}"); + let skipped = hosted_skipped(&env); + assert!( + skipped + .iter() + .any(|row| row["purl"] == PURL && row["reason"] == "redirect_unconfirmed"), + "{layout}: {env:#}" + ); + let warnings: Vec<&Value> = env["warnings"] + .as_array() + .into_iter() + .flatten() + .filter(|w| w["code"] == "redirect_pypi_no_lockfile") + .collect(); + assert_eq!(warnings.len(), 1, "{layout}: {env:#}"); + let detail = warnings[0]["detail"].as_str().unwrap(); + assert!( + detail.contains("requirements.txt") && detail.contains("uv.lock"), + "{layout}: {detail}" + ); + assert_eq!(tree(&root), before, "{layout}: hosted wrote nothing"); + } +} From d0dc53c0c287954f279581715fba5d201e0f9e5d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 06:17:17 +0000 Subject: [PATCH 4/4] Name each project's own lock step in the warning Review follow-up for redirect_pypi_no_lockfile. The remedy now follows the project's tooling: a Pipfile is told to run `pipenv lock`, and a pyproject that names uv, Poetry or PDM (a [tool.*] table or its build backend) is told to run that tool's lock. Any other pyproject, such as a plain setuptools project, gets both a root requirements.txt pin and the lock commands. The detail also lists every input that would silence it (pylock..toml, PEP 723 script locks, Hatch) and says "readable", since an unreadable file on disk is treated as missing. The presence check now matches root keys only, like the rewriters it stands in for. Refs #638 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 66 ++++++++++++---- .../patch/redirect/pypi_no_lockfile_tests.rs | 77 ++++++++++++++++++- 2 files changed, 128 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d9137f2c8..1df65c0c3 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1040,32 +1040,70 @@ fn warn_no_pypi_wiring( return; } let wired = files.keys().any(|key| { - let name = key.rsplit('/').next().unwrap_or(key); key == crate::formats::governing_locks::PYPI_REQUIREMENTS - || crate::formats::governing_locks::PYPI_TOOL_LOCKS.contains(&name) - || crate::utils::python_lock::is_python_lock_name(name) + || crate::formats::governing_locks::PYPI_TOOL_LOCKS.contains(&key.as_str()) + || crate::utils::python_lock::is_python_lock_name(key) }); if wired || crate::utils::hatch::is_hatch(files) { return; } - let remedy = if files.contains_key("pyproject.toml") { - "pyproject.toml declares the dependencies but no lock is present; \ - generate and commit the lock its tool installs from (`uv lock`, \ - `poetry lock`, `pdm lock`) and re-run" - } else { - "pin the package in a root requirements.txt or commit the project's \ - lock and re-run" - }; result.warnings.push(RewriteWarning { code: "redirect_pypi_no_lockfile".into(), detail: format!( - "no requirements.txt, uv.lock, poetry.lock, pdm.lock, Pipfile.lock or \ - pylock.toml present at the project root (hosted mode does not read \ - other requirements files such as requirements-dev.txt); {remedy}" + "no readable requirements.txt, uv.lock, poetry.lock, pdm.lock, \ + Pipfile.lock, pylock.toml / pylock..toml, PEP 723 script lock \ + (