Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ The hidden alias `--no-apply` on `get --save-only` is **part of the contract**

`repair` keeps its `gc` visible alias.

**Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `redirect.warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`.
**Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs, the project's Hatch environments (Hatch's data dir / `HATCH_DATA_DIR`, `[dirs.env] virtual`, explicit env `path`s) and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `redirect.warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`. A stale Hatch environment instead names `hatch env remove <env>` / `hatch env prune`: Hatch's pip installer (and uv before Hatch 1.16) keeps a same-version release, so only a recreated env picks up the patch. The same Hatch envs are what agent mode patches and `vex` judges for a Hatch project.

### socket.yml patch policy (v5.0)

Expand Down Expand Up @@ -327,7 +327,7 @@ Contract details:
* **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`.
* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold vendored state) and `vendor`'s (`No manifest found, nothing to vendor.` — a project with hosted pins ejects instead, v5.0) still generate the document from the lockfiles and the vendor ledger (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`.
* **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly.
* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the authenticated API refused the credentials and the public proxy served free patches only; `get` / `scan`'s warning text) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`<purl>: <why> (<errorCode>)` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: <purl> (<errorCode>)` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source).
* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove <env>`, since Hatch keeps an installed release); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the authenticated API refused the credentials and the public proxy served free patches only; `get` / `scan`'s warning text) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`<purl>: <why> (<errorCode>)` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: <purl> (<errorCode>)` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source).

### VEX provenance markers (contract)

Expand Down Expand Up @@ -1234,6 +1234,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `pypi_poetry_symlink_unsupported` / `pypi_pipenv_symlink_unsupported` / `pypi_requirements_symlink_unsupported` | `failed` | vendor (pypi, v5.0): a target file (`pyproject.toml` / `poetry.lock`, `Pipfile` / `Pipfile.lock`, or any planned `requirements*.txt`) is a symlink — refused before any write on wire AND on revert (the revert keeps the artifact, `kept_artifact`); the twins of the existing pdm/uv symlink refusals. |
| `pypi_poetry_changed` / `pypi_pdm_changed` / `pypi_pipenv_changed` / `pypi_uv_changed` | `failed` | vendor (pypi, v5.0): the lock / project file changed between the read that planned the edit and the first write — refused before any write (worded like `pypi_lock_changed`: "<file> changed during vendoring; re-run"). |
| `pypi_pipenv_stale_install` | `skipped` (warning) | vendor (pipenv): the vendored twin of `redirect_pypi_stale_install` — the project's venv still holds the upstream release Pipenv will not reinstall over; the detail names the `pipenv run pip uninstall -y <pkg> && pipenv sync` remedy. |
| `pypi_hatch_stale_install` | `skipped` (warning) | vendor (hatch): an existing Hatch environment of the project (found under Hatch's data dir, `dirs.env.virtual` or an explicit env `path`) still holds the upstream release; Hatch keeps it on the next `hatch run`, so the detail names the env and the `hatch env remove <env>` / `hatch env prune` remedy. |
| `pypi_pipenv_installer_unknown` | `skipped` (warning) | vendor (pipenv): no `pipenv` answered on PATH; the vendored references assume Pipenv 2018 or later (7–11 cannot consume them — use hosted mode there); `SOCKET_PIPENV_MAJOR` pins the release. |
| `vendor_lock_entry_relocked` | revert `warnings[]` | vendor `--revert` / rollback (pipenv): a relock regenerated the wired entry to a registry reference, or removed it; the record is retired (artifact removed, ledger entry dropped) instead of drift-kept. |
| `pypi_{poetry,pdm,pipenv}_no_lockfile` | `failed` | vendor (pypi): a lock-less tool marker with no `requirements.txt` fallback — run `<tool> lock`. |
Expand Down
62 changes: 61 additions & 1 deletion crates/socket-patch-cli/src/commands/scan/hosted/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ pub(super) async fn stale_install_warnings(
// fallback to the global interpreters would judge an unrelated Python's
// copy of the release (a tool venv on PATH) and warn falsely.
// --global / --global-prefix keep their meaning.
// Hatch envs need their own remedy: a reinstall from the rewritten
// pyproject does nothing there (#335).
let hatch_envs = if common.is_global() {
Vec::new()
} else {
socket_patch_core::crawlers::hatch_env::hatch_environments(&common.cwd).await
};
let paths = if common.is_global() {
crawler
.get_site_packages_paths(&common.crawler_options())
Expand Down Expand Up @@ -105,7 +112,11 @@ pub(super) async fn stale_install_warnings(
// (`install`, `install --deploy`, `sync` all keep the installed
// bytes on every major), and `pipenv uninstall` rewrites the
// Pipfile and re-locks the patch away — name the verified remedy.
let remedy = if pipenv_purls.contains(&purl) {
let hatch_env =
socket_patch_core::crawlers::hatch_env::environment_of(&hatch_envs, &site);
let remedy = if let Some(env) = hatch_env {
socket_patch_core::crawlers::hatch_env::stale_install_remedy(&env.name)
} else if pipenv_purls.contains(&purl) {
let name = strip_purl_qualifiers(&purl)
.strip_prefix("pkg:pypi/")
.and_then(|rest| rest.split('@').next())
Expand Down Expand Up @@ -214,6 +225,55 @@ mod tests {
assert!(out.warnings.is_empty());
}

/// #335: a Hatch env keeps the upstream release after the rewrite, and
/// the probe must find it (Hatch keeps envs out of `./.venv`) and name
/// Hatch's remedy, not "reinstall from the rewritten lock".
#[tokio::test]
async fn hatch_env_gets_the_stale_install_warning_with_hatch_remedy() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("app");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(
project.join("pyproject.toml"),
"[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n\n[tool.hatch.envs.default]\npath = \"../hatch-envs/app\"\n",
)
.unwrap();
let env = tmp.path().join("hatch-envs").join("app");
std::fs::create_dir_all(&env).unwrap();
std::fs::write(env.join("pyvenv.cfg"), "home = /usr/bin\n").unwrap();
let site = if cfg!(windows) {
env.join("Lib").join("site-packages")
} else {
env.join("lib").join("python3.12").join("site-packages")
};
let dist = site.join("six-1.16.0.dist-info");
std::fs::create_dir_all(&dist).unwrap();
std::fs::write(dist.join("METADATA"), "Name: six\nVersion: 1.16.0\n").unwrap();
std::fs::write(site.join("six.py"), b"upstream").unwrap();

let common = crate::args::GlobalArgs {
cwd: project.clone(),
..Default::default()
};
let purl = "pkg:pypi/six@1.16.0";
let confirmed = vec![(purl.to_string(), "six-uuid".to_string())];
let ledger = BTreeMap::from([("k".into(), record("six-uuid", "six.py", b"patched"))]);
let out = stale_install_warnings(&common, &confirmed, &BTreeSet::new(), &ledger).await;
assert_eq!(out.stale_purls, BTreeSet::from([purl.to_string()]));
assert_eq!(out.warnings.len(), 1);
let detail = out.warnings[0]["detail"].as_str().unwrap();
assert!(detail.contains("hatch env remove default"), "{detail}");
assert!(
!detail.contains("Reinstall from the rewritten lock"),
"{detail}"
);

// Patched in the env: nothing to warn about.
std::fs::write(site.join("six.py"), b"patched").unwrap();
let out = stale_install_warnings(&common, &confirmed, &BTreeSet::new(), &ledger).await;
assert!(out.warnings.is_empty());
}

/// A legacy `.egg-info` install (pip < 23.1 building an sdist without
/// `wheel`) is a real copy pip keeps on `install -r`, so the hosted
/// stale-install guard must judge it like a `.dist-info` one (#447).
Expand Down
22 changes: 21 additions & 1 deletion crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,26 @@ async fn generate_vex(
// installed tree is present and running different bytes. Say so — a
// build that bypasses the vendor wiring is unpatched until the next
// package-manager install.
// A Hatch env is never resynced by an install: Hatch keeps a present
// release (#335), so name the remedy that recreates it.
let hatch_note = if outcome.vendored_out_of_sync.is_empty() || common.is_global() {
String::new()
} else {
match socket_patch_core::crawlers::hatch_env::hatch_environments(&common.cwd)
.await
.as_slice()
{
[] => String::new(),
envs => format!(
" A Hatch environment keeps an installed release on the next `hatch run`; \
recreate it instead ({}).",
envs.iter()
.map(|env| format!("`hatch env remove {}`", env.name))
.collect::<Vec<_>>()
.join(", ")
),
}
};
for purl in &outcome.vendored_out_of_sync {
note_warning(
warnings,
Expand All @@ -683,7 +703,7 @@ async fn generate_vex(
"{purl}: the installed tree does not match its vendored artifact; the \
attestation is based on the committed .socket/vendor artifact (the lockfile \
consumes it), but the live tree carries different bytes — re-run your \
package manager's install to resync it."
package manager's install to resync it.{hatch_note}"
),
);
}
Expand Down
Loading
Loading