Skip to content

Commit 345660e

Browse files
Fix vendored PDM missing stale-install warning (#641) (#1727)
* Start fix for #641 Assisted-by: Claude Code:claude-opus-5-5 * Warn when PDM < 2.11 keeps an unpatched install Vendoring a patch into a PDM project whose pdm.lock was written by PDM before 2.11 (lock_version 2, 4.3 or 4.4) left an env that already held the package unpatched: PDM keeps a same-version install on `pdm sync`, the scan said nothing, and `vex` attested not_affected with a "re-run your install" hint that does not help. The vendored PDM flavor now probes the env PDM installs into (its venv or PEP 582 __pypackages__) like the Pipenv and Hatch flavors, and warns pypi_pdm_stale_install with the remedy that release line supports: `pdm sync --reinstall` on PDM 2.x, or removing the installed copy before `pdm sync` on PDM 1.x, which has no --reinstall. vex's vendored_tree_out_of_sync hint names the same remedy. A 2.11+ lock stays silent. Fixes #641 Assisted-by: Claude Code:claude-opus-5-5 * Format the PDM stale-install changes Assisted-by: Claude Code:claude-opus-5-5 * Tighten the PDM stale-install advice Review follow-ups for the PDM < 2.11 stale-install warning: - warn on a superseding re-wire too, not only fresh and in-sync runs - name a copy inherited from the base interpreter of a --system-site-packages venv apart, with a remedy that reaches it - add vex's PDM hint only for a package pdm.lock wires to a vendored artifact, reading pdm.lock once per run instead of once per package - share the "keeps an installed release" lock-version check between hosted and vendored mode - say "PDM before 2.0" for lock_version 2, which PDM 0.x writes too Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d48d97 commit 345660e

8 files changed

Lines changed: 501 additions & 7 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ Contract details:
487487
* **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" }`.
488488
* `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`.
489489
* **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.
490-
* **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 public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — standalone `vex --json` carries it when it fetched records, and `scan --json` / `get --json` report this case in their top-level `warnings[]`) — 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).
490+
* **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; for a pypi package of a project whose governing pdm.lock was written by PDM < 2.11 it names `pdm sync --reinstall`, or removing the installed copy first on PDM 1.x); 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 public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — standalone `vex --json` carries it when it fetched records, and `scan --json` / `get --json` report this case in their top-level `warnings[]`) — 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).
491491

492492
### VEX provenance markers (contract)
493493

@@ -1649,6 +1649,7 @@ command's integration tests check its output.
16491649
| `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"). |
16501650
| `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, with the same lock-category `sync` arguments as the hosted warning. |
16511651
| `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. |
1652+
| `pypi_pdm_stale_install` | `skipped` (warning) | vendor (pdm): the project's PDM env (its venv, or PEP 582 `__pypackages__`) still holds the upstream release, and pdm.lock was written by PDM < 2.11 (lock_version `2` / `4.3` / `4.4`), which keeps a same-version install on `pdm sync` / `pdm install`; the detail names `pdm sync --reinstall`, or for PDM 1.x (lock_version `2`, no `--reinstall`) removing the installed copy before `pdm sync`. Emitted on a fresh vendor and on an in-sync re-run while the env stays stale. |
16521653
| `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. |
16531654
| `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. |
16541655
| `vendor_lock_entry_pruned_kept` | revert `warnings[]` | vendor `--revert` / rollback (yarn berry, #737): vendoring dropped a lock entry only the patched package's implicit `node-gyp` dependency reached (or one descriptor of a shared entry), and the lock has re-added or changed that entry since; it is left as it is (not drift: the artifact is still removed). Run `yarn install` if the lock is incomplete. |

‎crates/socket-patch-cli/src/commands/vex.rs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1062,7 +1062,19 @@ async fn generate_vex(
10621062
),
10631063
}
10641064
};
1065+
// PDM < 2.11 keeps an installed release on `pdm sync` (#641): a
1066+
// package its pdm.lock wires gets the remedy that resyncs it.
1067+
let pdm_lock = if outcome.vendored_out_of_sync.is_empty() || common.is_global() {
1068+
None
1069+
} else {
1070+
socket_patch_core::vendor::pypi_pdm::stale_install_lock(&common.cwd).await
1071+
};
10651072
for purl in &outcome.vendored_out_of_sync {
1073+
let pdm_note = pdm_lock
1074+
.as_ref()
1075+
.and_then(|lock| socket_patch_core::vendor::pypi_pdm::stale_install_note(lock, purl))
1076+
.map(|note| format!(" {note}"))
1077+
.unwrap_or_default();
10661078
note_warning(
10671079
warnings,
10681080
common,
@@ -1071,7 +1083,7 @@ async fn generate_vex(
10711083
"{purl}: the installed tree does not match its vendored artifact; the \
10721084
attestation is based on the committed .socket/vendor artifact (the lockfile \
10731085
consumes it), but the live tree carries different bytes — re-run your \
1074-
package manager's install to resync it.{hatch_note}"
1086+
package manager's install to resync it.{hatch_note}{pdm_note}"
10751087
),
10761088
);
10771089
}

‎crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,14 @@ fn flow(mode: Mode) {
417417
.unwrap();
418418
assert_attested(&doc, PURL, mode.uuid(), mode.marker(), VULNS);
419419
std::fs::remove_file(&vex_out).unwrap();
420+
// The vendored scan ran over a venv that already holds upstream six.
421+
let stale_warnings: Vec<String> = env["events"]
422+
.as_array()
423+
.into_iter()
424+
.flatten()
425+
.filter(|e| e["errorCode"] == "pypi_pdm_stale_install")
426+
.map(|e| e.to_string())
427+
.collect();
420428
let wired_lock = std::fs::read_to_string(project.join("pdm.lock")).unwrap();
421429
assert!(
422430
!project
@@ -533,6 +541,86 @@ fn flow(mode: Mode) {
533541
matrix.run(&fresh, project, &|dir: &Path| {
534542
std::fs::write(dir.join("pdm.lock"), &pristine_lock).unwrap();
535543
});
544+
545+
if mode == Mode::Vendored {
546+
stale_install_flow(&pdm, tmp.path(), &locked, &wired_lock, &stale_warnings);
547+
}
548+
}
549+
550+
/// #641: PDM before 2.11 (lock_version 2 / 4.3 / 4.4) keeps the upstream six
551+
/// the project venv already held when the vendored scan wired pdm.lock, so
552+
/// the scan must warn `pypi_pdm_stale_install`, and the remedy it names must
553+
/// install the patched wheel. A 2.11+ lock reinstalls on `pdm sync`: silent.
554+
fn stale_install_flow(
555+
pdm: &Pdm,
556+
case: &Path,
557+
locked: &Locked,
558+
wired_lock: &str,
559+
stale_warnings: &[String],
560+
) {
561+
let what = format!(
562+
"pdm {} lock {} stale install",
563+
pdm.version, locked.lock_version
564+
);
565+
let project = &locked.project;
566+
let keeps = matches!(locked.lock_version.as_str(), "2" | "4.3" | "4.4");
567+
if !keeps {
568+
assert!(stale_warnings.is_empty(), "{what}: {stale_warnings:?}");
569+
return;
570+
}
571+
assert_eq!(stale_warnings.len(), 1, "{what}: {stale_warnings:?}");
572+
let python = venv_bin(&project.join(".venv"), "python");
573+
let (_, _, marked) = six_oracle(&python, project).unwrap();
574+
assert!(
575+
!marked,
576+
"{what}: the scan never touches the installed files"
577+
);
578+
assert_eq!(
579+
std::fs::read_to_string(project.join("pdm.lock")).unwrap(),
580+
wired_lock,
581+
"{what}: the project keeps the wired lock"
582+
);
583+
584+
// The next `pdm sync` keeps the upstream bytes, as the warning says.
585+
let envs = pdm.case_env(&case.join("stale-case"), project);
586+
let out = pdm.sync(project, &envs);
587+
let (_, _, marked) = six_oracle(&python, project).unwrap();
588+
assert!(!marked, "{what}: PDM < 2.11 keeps six: {}", out_text(&out));
589+
590+
// The named remedy installs the patched wheel.
591+
let out = if locked.lock_version == "2" {
592+
assert!(
593+
stale_warnings[0].contains("no `pdm sync --reinstall`"),
594+
"{what}: {}",
595+
stale_warnings[0]
596+
);
597+
let (module, _, _) = six_oracle(&python, project).unwrap();
598+
let site = module.parent().unwrap();
599+
std::fs::remove_file(&module).unwrap();
600+
std::fs::remove_dir_all(site.join("six-1.16.0.dist-info")).unwrap();
601+
pdm.sync(project, &envs)
602+
} else {
603+
assert!(
604+
stale_warnings[0].contains("`pdm sync --reinstall`"),
605+
"{what}: {}",
606+
stale_warnings[0]
607+
);
608+
pdm.run(project, &envs, &["sync", "--reinstall", "--no-self"])
609+
};
610+
let (_, _, marked) = six_oracle(&python, project)
611+
.unwrap_or_else(|| panic!("{what}: six missing after the remedy: {}", out_text(&out)));
612+
assert!(
613+
marked,
614+
"{what}: the remedy installs the patch: {}",
615+
out_text(&out)
616+
);
617+
record(
618+
"pdm",
619+
&pdm.version,
620+
&format!("lock {}/vendored", locked.lock_version),
621+
"stale-install-warned",
622+
"pass",
623+
);
536624
}
537625

538626
#[test]

‎crates/socket-patch-core/src/patch/redirect/pdm.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ pub(super) fn rewrite(
6464
// warning: warn proactively once per lock, mirroring poetry's
6565
// `redirect_poetry_stale_install_risk`.
6666
if !stale_warned
67-
&& matches!(lock_ver.as_deref(), Some("2") | Some("4.3") | Some("4.4"))
67+
&& lock_ver
68+
.as_deref()
69+
.is_some_and(crate::utils::pdm_lock::keeps_installed_release)
6870
{
6971
stale_warned = true;
7072
result.warnings.push(RewriteWarning {

‎crates/socket-patch-core/src/utils/pdm_lock.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ pub fn lock_version(lock: &Table) -> Result<&str, String> {
2323
}
2424
}
2525

26+
/// Whether the PDM release that writes `lock_version` keeps a release
27+
/// already installed at the same version on `pdm sync` / `pdm install`:
28+
/// PDM before 2.11 (lock_version `2` / `4.3` / `4.4`; measured on 1.4.5,
29+
/// 2.8.2, 2.9.3 and 2.10.4, while 2.11+ reinstall).
30+
pub fn keeps_installed_release(lock_version: &str) -> bool {
31+
matches!(lock_version, "2" | "4.3" | "4.4")
32+
}
33+
2634
pub fn validate_strategy(lock: &Table) -> Result<Vec<String>, String> {
2735
let mut result = Vec::new();
2836
let Some(metadata) = lock.get("metadata") else {

0 commit comments

Comments
 (0)