Skip to content

Commit 006ae72

Browse files
Warn on yarn classic member-dir vendored installs (#691) (#1324)
* Start yarn classic member-dir fix (#691) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Warn on yarn classic member-dir vendored installs Vendored yarn classic wiring resolves `file:./.socket/vendor/...` tarballs, and yarn 1 reads a relative `file:` path from the directory it runs in. In a workspaces project, every cold-cache yarn command run from a member directory then fails with "Tarball is not in network". No relative spelling installs from both the root and a member (measured on yarn 1.22.22). Vendored runs in such a project now warn `yarn_classic_workspace_member_install_risk` and name the remedy (install from the workspace root, or use hosted mode). The limitation is documented. Whether to also ship the tarball into each member is open on the issue. Refs #691 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Gate symlink lock test back under cfg(unix) for Windows (#691) The #691 test was inserted between the #627 test's doc comment and its #[cfg(unix)] attribute, so the gate (and the #627 doc) moved onto the new portable yarn test and left vendor_refuses_a_symlinked_lock_instead_of_replacing_it, which calls std::os::unix::fs::symlink, ungated. Windows test builds failed with E0433. Move the new test above the #627 doc so each test keeps its own attributes, and rustfmt the new probe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 1ead8bb commit 006ae72

5 files changed

Lines changed: 163 additions & 15 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,10 @@ the model is **not uniform** today:
592592
vlt.json `workspaces` — a glob, a list, or named groups of either — or vlt <= 0.0.0-12's
593593
`vlt-workspaces.json`; vlt's declaration wins over the others and vlt never reads package.json
594594
`workspaces`). One repo-root invocation discovers every member. A member that is itself a
595-
workspace root is recursed into (bounded depth).
595+
workspace root is recursed into (bounded depth). Vendored yarn classic wiring is root-relative (`file:./.socket/vendor/…`), which yarn 1
596+
reads relative to the directory it runs in: a cold-cache yarn command run from a member
597+
directory cannot fetch it, and the run warns `yarn_classic_workspace_member_install_risk`
598+
(#691; see docs/ecosystems.md).
596599
- **cwd-only (single project):** gem, pypi, composer. The crawler inspects only the project
597600
rooted at `--cwd` (pypi first takes the env the project's manager records: PDM's `.pdm-python` interpreter, meaning its venv or, for a base interpreter, PEP 582 `__pypackages__/<X.Y>/lib`, and uv's `UV_PROJECT_ENVIRONMENT`. Otherwise it looks at `$VIRTUAL_ENV`, `<cwd>/.venv` / `venv`, then a Poetry project's out-of-tree virtualenv(s) under Poetry's `virtualenvs.path`; composer at the vendor tree); it does **not**
598601
descend into sibling subprojects. A monorepo with several independent lockfiles in subdirectories

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

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -865,28 +865,33 @@ fn format_revert_install_hint(cmd: &str) -> String {
865865
)
866866
}
867867

868-
/// Run-level advisory shared by the `vendor` command and the scan-driven
868+
/// Run-level advisories shared by the `vendor` command and the scan-driven
869869
/// vendor step: warn (once, at the envelope level — not per package) when
870870
/// the project's classic `yarn.lock` carries vendored wiring that a stray
871-
/// yarn 2+ install would silently drop. The probe is state-based (it reads
872-
/// the on-disk lockfile), so callers invoke it unconditionally at
873-
/// envelope-finalize time — unwired projects and fully-reverted runs stay
874-
/// silent, and dry runs report the risk that already exists on disk.
871+
/// yarn 2+ install would silently drop, or that installs run from a
872+
/// workspace member directory cannot fetch (#691). The probes are
873+
/// state-based (they read the on-disk lockfile), so callers invoke them
874+
/// unconditionally at envelope-finalize time — unwired projects and
875+
/// fully-reverted runs stay silent, and dry runs report the risk that
876+
/// already exists on disk.
875877
pub(crate) fn note_classic_migration_risk(
876878
env: &mut Envelope,
877879
project_root: &Path,
878880
common: &GlobalArgs,
879881
) {
880-
let Some(w) = vendor::yarn_classic_berry_migration_risk(project_root) else {
881-
return;
882-
};
883-
if !common.silent && !common.json {
884-
eprintln!("Warning: {}", w.detail);
882+
let warnings = [
883+
vendor::yarn_classic_berry_migration_risk(project_root),
884+
vendor::yarn_classic_workspace_member_risk(project_root),
885+
];
886+
for w in warnings.into_iter().flatten() {
887+
if !common.silent && !common.json {
888+
eprintln!("Warning: {}", w.detail);
889+
}
890+
env.warnings.push(RunWarning {
891+
code: w.code.to_string(),
892+
detail: w.detail,
893+
});
885894
}
886-
env.warnings.push(RunWarning {
887-
code: w.code.to_string(),
888-
detail: w.detail,
889-
});
890895
}
891896

892897
/// The usage error for `vendor` under global scope, or `None` for a

‎crates/socket-patch-cli/tests/covgap_commands_vendor.rs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,6 +1194,52 @@ async fn vendor_state_write_failure_reports_failed_event() {
11941194
);
11951195
}
11961196

1197+
/// #691: yarn 1 resolves the vendored `file:./.socket/vendor/…` tarball
1198+
/// against the directory it runs in, so in a workspaces project every
1199+
/// cold-cache install run from a member directory fails once vendoring
1200+
/// wires one. The vendor run must say so (and say nothing for a project
1201+
/// without workspaces).
1202+
#[test]
1203+
fn yarn_classic_workspaces_vendor_warns_about_member_dir_installs() {
1204+
const CODE: &str = "yarn_classic_workspace_member_install_risk";
1205+
let yarn_lock = format!(
1206+
"# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\
1207+
# yarn lockfile v1\n\n\n\
1208+
left-pad@^1.3.0:\n version \"1.3.0\"\n resolved \"{REG_RESOLVED}\"\n \
1209+
integrity {REG_INTEGRITY}\n"
1210+
);
1211+
for workspaces in [true, false] {
1212+
let fx = npm_fixture();
1213+
std::fs::remove_file(fx.lock_path()).unwrap();
1214+
let manifest = if workspaces {
1215+
std::fs::create_dir_all(fx.root().join("a")).unwrap();
1216+
std::fs::write(
1217+
fx.root().join("a/package.json"),
1218+
br#"{"name":"a","version":"1.0.0","dependencies":{"left-pad":"^1.3.0"}}"#,
1219+
)
1220+
.unwrap();
1221+
r#"{"name":"fixture","version":"1.0.0","private":true,"workspaces":["a"]}"#
1222+
} else {
1223+
r#"{"name":"fixture","version":"1.0.0","private":true,"dependencies":{"left-pad":"^1.3.0"}}"#
1224+
};
1225+
std::fs::write(fx.root().join("package.json"), manifest).unwrap();
1226+
std::fs::write(fx.root().join("yarn.lock"), &yarn_lock).unwrap();
1227+
1228+
let (code, env) = vendor_cli(fx.root(), &[]);
1229+
assert_eq!(code, 0, "{env:#}");
1230+
assert!(
1231+
std::fs::read_to_string(fx.root().join("yarn.lock"))
1232+
.unwrap()
1233+
.contains("resolved \"file:./.socket/vendor/"),
1234+
"{env:#}"
1235+
);
1236+
let warned = env["warnings"]
1237+
.as_array()
1238+
.is_some_and(|ws| ws.iter().any(|w| w["code"] == CODE));
1239+
assert_eq!(warned, workspaces, "workspaces={workspaces}: {env:#}");
1240+
}
1241+
}
1242+
11971243
/// #627: a symlinked lockfile (a lock shared with another checkout) is
11981244
/// refused like hosted mode refuses it — `redirect_symlinked_file_unsupported`,
11991245
/// exit 1 — instead of being renamed over: the link survives, its target

‎crates/socket-patch-core/src/vendor/mod.rs‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,50 @@ pub fn yarn_classic_berry_migration_risk(project_root: &Path) -> Option<VendorWa
204204
))
205205
}
206206

207+
/// Advisory probe (#691): does this yarn classic workspaces project carry
208+
/// vendored wiring that installs from a workspace member directory cannot
209+
/// fetch?
210+
///
211+
/// Vendored mode wires `resolved "file:./.socket/vendor/…"`, relative to
212+
/// the workspace root that holds `yarn.lock`. Yarn 1 resolves a relative
213+
/// `file:` tarball against the directory it runs in, so on a cold cache
214+
/// every `yarn install` / `yarn add` run from a member directory (and
215+
/// `yarn workspace <name> …`, which runs there) fails with "Tarball is not
216+
/// in network and can not be located in cache", whether or not the member
217+
/// depends on the patched package. No relative spelling works from both
218+
/// (measured on yarn 1.7.0, 1.10.1 and 1.22.22). Returns the warning when
219+
/// `yarn.lock` is classic with such a `file:./` resolution and the root
220+
/// `package.json` declares workspaces. State-based, like
221+
/// [`yarn_classic_berry_migration_risk`].
222+
pub fn yarn_classic_workspace_member_risk(project_root: &Path) -> Option<VendorWarning> {
223+
let lock = read_regular_to_string_sync(&project_root.join("yarn.lock")).ok()?;
224+
if !lock.contains("# yarn lockfile v1") || !lock.contains("\"file:./.socket/vendor/") {
225+
return None;
226+
}
227+
let manifest = read_regular_to_string_sync(&project_root.join("package.json")).ok()?;
228+
let manifest: serde_json::Value =
229+
serde_json::from_str(crate::formats::text::strip_bom(&manifest)).ok()?;
230+
let workspaces = manifest.get("workspaces")?;
231+
let globs = workspaces.as_array().or_else(|| {
232+
workspaces
233+
.get("packages")
234+
.and_then(serde_json::Value::as_array)
235+
})?;
236+
if globs.is_empty() {
237+
return None;
238+
}
239+
Some(VendorWarning::new(
240+
"yarn_classic_workspace_member_install_risk",
241+
"yarn.lock is yarn-classic (v1) in a workspaces project and its vendored entries \
242+
resolve `file:./.socket/vendor/…` tarballs, which yarn 1 looks up relative to the \
243+
directory it runs in: on a cold yarn cache, `yarn install`, `yarn add` or `yarn \
244+
workspace <name> …` run from a workspace member directory fails (\"Tarball is not \
245+
in network\"). Run `yarn install` from the workspace root (which also warms the \
246+
cache for later member-directory commands), or patch this project with `--mode \
247+
hosted`.",
248+
))
249+
}
250+
207251
/// Whether a root `package.json` text pins yarn classic through corepack's
208252
/// `packageManager: yarn@1…`, which makes a stray yarn 2+ (berry) install
209253
/// refuse instead of migrating a classic `yarn.lock` and dropping its
@@ -2097,6 +2141,46 @@ mod berry_migration_risk_tests {
20972141
assert!(probe_with_timeout(tmp.path(), &fifo).is_some());
20982142
}
20992143

2144+
/// #691: yarn 1 resolves a relative `file:` tarball in `resolved`
2145+
/// against the directory it runs in, not the one holding yarn.lock, so
2146+
/// in a workspaces project every cold-cache install run from a member
2147+
/// directory fails once vendoring wires one. Measured on yarn 1.7.0,
2148+
/// 1.10.1 and 1.22.22; no relative spelling installs from both.
2149+
#[test]
2150+
fn issue_691_wired_classic_workspaces_warn_about_member_dir_installs() {
2151+
for workspaces in [r#"["a","b"]"#, r#"{"packages":["packages/*"]}"#] {
2152+
let pkg = format!(r#"{{"name":"root","private":true,"workspaces":{workspaces}}}"#);
2153+
let tmp = project(Some(WIRED_V1), Some(&pkg));
2154+
let w = yarn_classic_workspace_member_risk(tmp.path()).expect("must warn");
2155+
assert_eq!(w.code, "yarn_classic_workspace_member_install_risk");
2156+
assert!(
2157+
w.detail.contains("member directory") && w.detail.contains("workspace root"),
2158+
"detail names the trap and the remedy: {}",
2159+
w.detail
2160+
);
2161+
}
2162+
// No workspaces, empty workspaces, an unwired or berry lock, or
2163+
// no manifest: nothing installs from a member directory through
2164+
// our wiring.
2165+
for (lock, pkg) in [
2166+
(Some(WIRED_V1), Some(r#"{"name":"x"}"#)),
2167+
(Some(WIRED_V1), Some(r#"{"name":"x","workspaces":[]}"#)),
2168+
(
2169+
Some(WIRED_V1),
2170+
Some(r#"{"name":"x","workspaces":{"packages":[]}}"#),
2171+
),
2172+
(Some(WIRED_V1), None),
2173+
(Some(UNWIRED_V1), Some(r#"{"name":"x","workspaces":["a"]}"#)),
2174+
(None, Some(r#"{"name":"x","workspaces":["a"]}"#)),
2175+
] {
2176+
let tmp = project(lock, pkg);
2177+
assert!(
2178+
yarn_classic_workspace_member_risk(tmp.path()).is_none(),
2179+
"{lock:?} {pkg:?}"
2180+
);
2181+
}
2182+
}
2183+
21002184
#[test]
21012185
fn malformed_or_missing_package_json_still_warns() {
21022186
// Fail toward warning: an unreadable pin must not silently vouch

‎docs/ecosystems.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,16 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5:
268268
vendored wiring alike, so the packages install unpatched. A run that leaves such a pin
269269
warns (`redirect_yarn_classic_berry_migration_risk` / `yarn_classic_berry_migration_risk`)
270270
unless `package.json` pins yarn classic through `"packageManager": "yarn@1…"`.
271+
- **yarn classic workspaces, vendored** — vendored mode wires
272+
`resolved "file:./.socket/vendor/…"`, relative to the workspace root. Yarn 1 looks a
273+
relative `file:` tarball up from the directory it runs in, so on a cold yarn cache
274+
`yarn install`, `yarn add` or `yarn workspace <name> …` run from a member directory
275+
fails ("Tarball is not in network and can not be located in cache"), whether or not the
276+
member depends on the patched package (yarn 1.7.0, 1.10.1 and 1.22.22; no relative
277+
spelling installs from both). Installs from the workspace root work, and warm the cache
278+
for later member-directory commands. A vendored run in such a project warns
279+
`yarn_classic_workspace_member_install_risk`; hosted mode, which pins an absolute URL,
280+
is not affected.
271281
- **yarn classic git dependencies** — yarn 1 fetches a git pattern (`git+https:`,
272282
`git+ssh:`, `git:`, `ssh:`, a `….git` url, or a bare `https://github.com/<owner>/<repo>`)
273283
with git, using the lock entry's `resolved` as the remote, so a rewritten `resolved`

0 commit comments

Comments
 (0)