Skip to content
Merged
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1362,7 +1362,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `redirect_unconfirmed` | `redirect.patches[]` `unpinned` row | hosted `scan` / `get` (v5.0, additive): the patch was granted but no lockfile entry pinning it could be rewritten. The status and exit code are unchanged for now, pending the open hosted exit-policy decision (#704); `--silent` hides the human line. |
| `lockfile_unreadable` / `lockfile_unparseable` / `patched_ref_invalid` / `patched_ref_unattributable` | run-level `warnings[]` | vex (every form): lockfile-discovery diagnostics — see "Manifest-less VEX (lockfile discovery)". Never flip the exit on their own. |
| `vex_npm_shrinkwrap_only` | run warning and `failed[].reason` | vex (every form, #899): the patch is wired only in a root `npm-shrinkwrap.json` with no `package-lock.json` twin, which npm >= 12 never reads; no statement until the twin exists. `list` / `rollback` / `remove` still manage the wiring. |
| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. |
| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` output) is wired with the lock instead and never named here (#612): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, and a re-run over a project whose `Pipfile.lock` is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. |
| `vendor_npm_shrinkwrap_only` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #899): the package was wired into `npm-shrinkwrap.json`, the only npm lock (also on an in-sync re-run). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. |
| `vendor_workspace_member_skipped` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #688): a lock entry with the package's `name@version` is the project's own source (a workspace member or a `file:` directory), so it is left alone while the lock's registry copies are vendored; patch that source directly if it needs the fix. When it is the only instance, vendoring refuses instead. |
| `vendor_npm_allow_file` | `skipped` (warning, printed as `Warning (vendor_npm_allow_file)`) | vendor / scan / get `--mode vendored` (package-lock, #969): the effective npm `allow-file` setting (env > project `.npmrc` > user > global > builtin) is not `all`, so npm >= 11.14 refuses the vendored `file:` tarball (EALLOWFILE) on install. The setting is respected, never rewritten; the detail names where it comes from and the remedy (`allow-file=all`). `vendor --check` fails the entry for the same reason. |
Expand Down
133 changes: 123 additions & 10 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2022,12 +2022,11 @@ async fn platform_wheel_takeover_is_refused_before_revert() {

/// #612: a Pipenv project with a `requirements.txt` exported beside its
/// lock (`pipenv requirements`). Hosted mode pins both; the hosted →
/// vendored takeover (`vendor` over the hosted project) wires only the
/// governing `Pipfile.lock` and restores the requirements pin to upstream,
/// so the run must name `requirements.txt` among the install sources left
/// UNPATCHED instead of passing in silence.
/// vendored takeover (`vendor` over the hosted project) must leave both
/// patched too: `Pipfile.lock` and the export's pin both refer to the
/// vendored wheel, and no install source is left UNPATCHED.
#[tokio::test]
async fn pipenv_hosted_to_vendored_names_the_unpatched_requirements() {
async fn pipenv_hosted_to_vendored_keeps_the_requirements_patched() {
let (_tmp, root) = project();
stage_pipenv(&root);
std::fs::write(
Expand Down Expand Up @@ -2062,13 +2061,127 @@ async fn pipenv_hosted_to_vendored_names_the_unpatched_requirements() {
lock.contains(&format!(".socket/vendor/pypi/{UUID}/")),
"Pipfile.lock is wired to the vendored wheel:\n{lock}\n{env:#}"
);
let rendered = env.to_string();
let reqs = std::fs::read_to_string(root.join("requirements.txt")).unwrap();
assert!(
rendered.contains("\"pypi_multiple_lockfiles\"")
&& rendered.contains("wiring `Pipfile.lock`")
&& rendered.contains("requirements.txt will still install the UNPATCHED"),
"the takeover names requirements.txt as an unpatched install source: {env:#}"
reqs.contains(&format!(".socket/vendor/pypi/{UUID}/")) && !reqs.contains(&hosted_url),
"requirements.txt is wired to the vendored wheel too:\n{reqs}\n{env:#}"
);
assert!(
!env.to_string().contains("UNPATCHED"),
"no install source is left unpatched: {env:#}"
);
}

/// The `pipenv requirements > requirements.txt` export of [`stage_pipenv`]'s
/// lock: the file Docker / plain-pip installs read.
const PIPENV_EXPORT: &str = "-i https://pypi.org/simple\nsix==1.16.0 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'\n";

/// #612: vendored mode in a Pipenv project with an exported
/// requirements.txt must keep that install path patched too, the way
/// hosted mode rewrites both files: `vendor` wires Pipfile.lock AND the
/// export's pin, `vendor --check` is green, and the revert restores both.
/// The same holds when the export is added after a first vendor (a re-run
/// wires it instead of answering `already_vendored`), and for the hosted
/// → vendored takeover, which used to turn the export's hosted pin back
/// into a plain PyPI pin.
#[tokio::test]
async fn pipenv_vendor_wires_the_exported_requirements_too() {
let wheel_dir = format!(".socket/vendor/pypi/{UUID}/");
let assert_both_vendored = |root: &Path, label: &str| {
for f in ["Pipfile.lock", "requirements.txt"] {
let text = std::fs::read_to_string(root.join(f)).unwrap();
assert!(
text.contains(&wheel_dir),
"{label}: {f} is vendored:\n{text}"
);
}
let (code, env) = run_cli(root, &["vendor", "--check"], &[]);
assert_eq!(code, 0, "{label}: vendor --check is green: {env:#}");
};
let assert_reverts = |root: &Path, pipfile_lock: &str, label: &str| {
let (code, env) = run_cli(root, &["vendor", "--revert"], &[]);
assert_eq!(code, 0, "{label}: revert: {env:#}");
assert_eq!(
std::fs::read_to_string(root.join("requirements.txt")).unwrap(),
PIPENV_EXPORT,
"{label}: the export is restored byte for byte"
);
let lock = |t: &str| serde_json::from_str::<Value>(t).unwrap();
assert_eq!(
lock(&std::fs::read_to_string(root.join("Pipfile.lock")).unwrap()),
lock(pipfile_lock),
"{label}: Pipfile.lock is restored"
);
assert!(
!root.join(&wheel_dir).exists(),
"{label}: the wheel is reclaimed"
);
};

// Fresh vendor.
let (_tmp, root) = project();
stage_pipenv(&root);
let pristine = std::fs::read_to_string(root.join("Pipfile.lock")).unwrap();
std::fs::write(root.join("requirements.txt"), PIPENV_EXPORT).unwrap();
stage_manifest(&root);
let (code, env) = run_cli(&root, &["vendor"], &[]);
assert_eq!(code, 0, "vendor: {env:#}");
assert!(
!env.to_string().contains("UNPATCHED"),
"no install path is left unpatched: {env:#}"
);
assert_both_vendored(&root, "fresh");
assert_reverts(&root, &pristine, "fresh");

// The export reached through an in-root `-r` include is wired too.
let (_tmp, root) = project();
let files = stage_pipenv(&root);
std::fs::write(root.join("requirements.txt"), "-r req/base.txt\n").unwrap();
std::fs::create_dir_all(root.join("req")).unwrap();
std::fs::write(root.join("req/base.txt"), PIPENV_EXPORT).unwrap();
vendor_project(&root, files);
let base = std::fs::read_to_string(root.join("req/base.txt")).unwrap();
assert!(
base.contains(&wheel_dir),
"the included export is vendored:\n{base}"
);
let (code, env) = run_cli(&root, &["vendor", "--check"], &[]);
assert_eq!(code, 0, "include: vendor --check is green: {env:#}");
let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]);
assert_eq!(code, 0, "include: revert: {env:#}");
assert_eq!(
std::fs::read_to_string(root.join("req/base.txt")).unwrap(),
PIPENV_EXPORT
);

// The export appears after a first vendor: the re-run wires it.
let (_tmp, root) = project();
let files = stage_pipenv(&root);
vendor_project(&root, files);
std::fs::write(root.join("requirements.txt"), PIPENV_EXPORT).unwrap();
let (code, env) = run_cli(&root, &["vendor"], &[]);
assert_eq!(code, 0, "re-run: {env:#}");
assert_both_vendored(&root, "re-run");
assert_reverts(&root, &pristine, "re-run");

// Hosted → vendored takeover keeps both files patched.
let server = MockServer::start().await;
let hosted_url = mount_hosted_api(&server, true).await;
let uri = server.uri();
let (_tmp, root) = project();
stage_pipenv(&root);
std::fs::write(root.join("requirements.txt"), PIPENV_EXPORT).unwrap();
let (code, env) = hosted_scan(&root, &server);
assert_eq!(code, 0, "hosted scan: {env:#}");
for f in ["Pipfile.lock", "requirements.txt"] {
let text = std::fs::read_to_string(root.join(f)).unwrap();
assert!(text.contains(&hosted_url), "hosted first: {f}:\n{text}");
}
stage_manifest(&root);
prebuilt_common::mount_project(&server, &root).await;
let (code, env) = run_cli(&root, &["vendor", "--patch-server-url", uri.as_str()], &[]);
assert_eq!(code, 0, "takeover: {env:#}");
assert_both_vendored(&root, "takeover");
}

// ── #479: a Hatch-derived pylock.toml ────────────────────────────────────
Expand Down
Loading
Loading