Skip to content
Merged
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
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 @@ -1274,7 +1274,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed <code>` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail `<purl> was hosted; restored its upstream registry entry (<files>) before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). |
| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore <purl> to its upstream registry entry: <why>; restore it from version control instead (`git checkout -- <files>`)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. |
| `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/<uuid>`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. |
| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
| `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. |
| `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. |
| `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. |
Expand Down
105 changes: 105 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1635,6 +1635,111 @@ async fn hosted_scan_from_package_json_workspace_member_refuses() {
}
}

/// A workspace member `packages/a` holding the patched package, under a
/// root whose `manifest` (`package.json` or `vlt.json`) declares
/// `workspaces` and whose lock is `lock_name`.
fn write_workspace_member(
root: &Path,
manifest: &str,
manifest_text: &str,
lock_name: &str,
) -> std::path::PathBuf {
std::fs::write(root.join(manifest), manifest_text).unwrap();
if manifest != "package.json" {
std::fs::write(
root.join("package.json"),
r#"{ "name": "root", "private": true }"#,
)
.unwrap();
}
std::fs::write(root.join(lock_name), format!("# root lock {lock_name}\n")).unwrap();
let member = root.join("packages/a");
let pkg = member.join("node_modules").join(NAME);
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
member.join("package.json"),
format!(
r#"{{ "name": "a", "version": "1.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"#
),
)
.unwrap();
std::fs::write(
pkg.join("package.json"),
format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#),
)
.unwrap();
member
}

/// #1071: npm, yarn and Bun resolve `workspaces` with full glob syntax, so
/// a root listing `packages/{a,b}` or `packages/[a-c]` governs
/// `packages/a`. The member matcher compared `{` and `[` literally, so a
/// hosted run from the member pinned nothing and exited 0.
#[tokio::test]
#[serial]
async fn hosted_scan_from_brace_or_class_glob_workspace_member_refuses() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;
mock_view(&server).await;
for pattern in [
"packages/{a,b}",
"packages/[a-c]",
"{apps,packages}/*",
"packages/[!b]",
] {
let tmp = tempfile::tempdir().unwrap();
let member = write_workspace_member(
tmp.path(),
"package.json",
&format!(r#"{{ "name": "root", "private": true, "workspaces": ["{pattern}"] }}"#),
"package-lock.json",
);
let lock = tmp.path().join("package-lock.json");
let before = std::fs::read_to_string(&lock).unwrap();
let (code, doc) = run_hosted_json(&member, &server.uri());
assert_refused_workspace_lock_elsewhere(pattern, code, &doc, &lock, &before, &member);
}
}

/// #942: vlt reads its workspaces from `vlt.json` and keeps one
/// `vlt-lock.json` at that root, so a hosted run from a member found the
/// member's copy, read no lock, pinned nothing and exited 0. It now
/// refuses and names the vlt workspace root, for every `workspaces` shape
/// vlt accepts (a string, an array, an object of groups).
#[tokio::test]
#[serial]
async fn hosted_scan_from_vlt_workspace_member_refuses() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;
mock_view(&server).await;
for workspaces in [
r#""packages/*""#,
r#"["packages/*"]"#,
r#"{ "apps": "apps/*", "libs": ["packages/{a,b}"] }"#,
] {
let tmp = tempfile::tempdir().unwrap();
let member = write_workspace_member(
tmp.path(),
"vlt.json",
&format!(r#"{{ "workspaces": {workspaces} }}"#),
"vlt-lock.json",
);
let lock = tmp.path().join("vlt-lock.json");
let before = std::fs::read_to_string(&lock).unwrap();
let (code, doc) = run_hosted_json(&member, &server.uri());
assert_refused_workspace_lock_elsewhere(workspaces, code, &doc, &lock, &before, &member);
assert!(
doc["error"]
.as_str()
.unwrap_or_default()
.contains("vlt.json"),
"{workspaces}: the error names vlt.json: {doc}"
);
}
}

fn assert_refused_workspace_lock_elsewhere(
case: &str,
code: Option<i32>,
Expand Down
Loading
Loading