Skip to content

Commit c8479ae

Browse files
feat(vendor): warn when a wired classic yarn.lock is one berry install from silent de-patching (#133)
Proven end-to-end on a real monorepo (2026-07): yarn 2+ (berry) migrates a classic (v1) yarn.lock to its own format on install and re-resolves every entry from the registry — the vendored `file:./.socket/vendor/…` resolutions are dropped with NO warning and every package installs unpatched. New state-based core probe `yarn_classic_berry_migration_risk` (vendor/mod.rs): fires when yarn.lock is classic AND carries vendored wiring AND package.json does not pin yarn classic via `packageManager: yarn@1…` (a corepack pin makes stray berry installs refuse instead of migrate; major is parsed, so yarn@10 does not string-match yarn@1). Reads on-disk state, not run events, so callers invoke it unconditionally at envelope-finalize: unwired projects, yarn@1-pinned projects, and fully-reverted runs stay silent. Surfaced as a new run-level `warnings` array on the JSON envelope (`{code, detail}`, omitted when empty so existing consumers see byte-identical output) plus a stderr line in human mode — wired into the `vendor` command and both scan vendor flows (shared run_scan_vendor_step). Tests: 5 probe unit tests (pin suppression, yarn@4/yarn@10/pnpm pins still warn, unwired/berry/missing locks silent, malformed package.json fails toward warning); the yarn-classic capstone e2e now asserts the advisory appears on first vendor AND on in-sync re-runs, disappears under a yarn@1.22.22 packageManager pin (and the empty array is omitted from JSON), and falls silent after --revert. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 86a6ffc commit c8479ae

5 files changed

Lines changed: 240 additions & 3 deletions

File tree

crates/socket-patch-cli/src/commands/scan/vendor_flow.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ use std::time::Duration;
1717
use crate::args::GlobalArgs;
1818
use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome};
1919
use crate::commands::get::{download_and_apply_patches, download_patch_records, DownloadParams};
20-
use crate::commands::vendor::{reconcile_dropped, track_outcomes_for_vendor, vendor_records};
20+
use crate::commands::vendor::{
21+
note_classic_migration_risk, reconcile_dropped, track_outcomes_for_vendor, vendor_records,
22+
};
2123
use crate::json_envelope::{Command as EnvelopeCommand, Envelope};
2224

2325
use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc};
@@ -100,7 +102,10 @@ async fn run_scan_vendor_step(
100102
Ok(Some(m)) => m,
101103
Ok(None) => {
102104
// No manifest ⇒ nothing downloaded and nothing
103-
// pre-existing to vendor: a clean no-op.
105+
// pre-existing to vendor: a clean no-op. Wiring from a
106+
// previous run may still sit in the lockfile, so the
107+
// state-based migration-risk advisory still applies.
108+
note_classic_migration_risk(&mut env, &common.cwd, common);
104109
drop(guard);
105110
return Ok((false, env));
106111
}
@@ -130,6 +135,7 @@ async fn run_scan_vendor_step(
130135
if has_errors {
131136
env.mark_partial_failure();
132137
}
138+
note_classic_migration_risk(&mut env, &common.cwd, common);
133139
Ok((has_errors, env))
134140
}
135141

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ use crate::commands::lock_cli::acquire_or_emit;
4242
use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs};
4343
use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls};
4444
use crate::json_envelope::{
45-
Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status, VexSummary,
45+
Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning, Status, VexSummary,
4646
};
4747

4848
#[derive(Args)]
@@ -254,6 +254,30 @@ pub(crate) fn record_warning(
254254
);
255255
}
256256

257+
/// Run-level advisory shared by the `vendor` command and the scan-driven
258+
/// vendor step: warn (once, at the envelope level — not per package) when
259+
/// the project's classic `yarn.lock` carries vendored wiring that a stray
260+
/// yarn 2+ install would silently drop. The probe is state-based (it reads
261+
/// the on-disk lockfile), so callers invoke it unconditionally at
262+
/// envelope-finalize time — unwired projects and fully-reverted runs stay
263+
/// silent, and dry runs report the risk that already exists on disk.
264+
pub(crate) fn note_classic_migration_risk(
265+
env: &mut Envelope,
266+
project_root: &Path,
267+
common: &GlobalArgs,
268+
) {
269+
let Some(w) = vendor::yarn_classic_berry_migration_risk(project_root) else {
270+
return;
271+
};
272+
if !common.silent && !common.json {
273+
eprintln!("Warning ({}): {}", w.code, w.detail);
274+
}
275+
env.warnings.push(RunWarning {
276+
code: w.code.to_string(),
277+
detail: w.detail,
278+
});
279+
}
280+
257281
pub async fn run(args: VendorArgs) -> i32 {
258282
apply_env_toggles(&args.common);
259283
let (telemetry_client, use_public_proxy) =
@@ -355,6 +379,8 @@ pub async fn run(args: VendorArgs) -> i32 {
355379
}
356380
}
357381

382+
note_classic_migration_risk(&mut env, &args.common.cwd, &args.common);
383+
358384
if args.common.json {
359385
println!("{}", env.to_pretty_json());
360386
}

crates/socket-patch-cli/src/json_envelope.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ pub struct Envelope {
7272
/// against ecosystems with no sidecar contract (e.g. npm).
7373
#[serde(skip_serializing_if = "Vec::is_empty")]
7474
pub sidecars: Vec<SidecarRecord>,
75+
/// Run-level advisories that are about the PROJECT's state rather than
76+
/// any single package (e.g. `yarn_classic_berry_migration_risk`: the
77+
/// wired classic lockfile would be silently de-patched by a yarn 2+
78+
/// install). Distinct from per-purl `events` — consumers alert on these
79+
/// without attributing them to a package. Empty (and omitted from JSON)
80+
/// for runs with nothing to advise, so existing consumers see byte-
81+
/// identical output.
82+
#[serde(skip_serializing_if = "Vec::is_empty")]
83+
pub warnings: Vec<RunWarning>,
7584
/// Present only when `--vex <path>` was passed to `apply`/`scan` and
7685
/// an OpenVEX document was successfully generated as a side-effect of
7786
/// the run. Describes where it landed and how many statements it
@@ -108,6 +117,7 @@ impl Envelope {
108117
summary: Summary::default(),
109118
error: None,
110119
sidecars: Vec::new(),
120+
warnings: Vec::new(),
111121
vex: None,
112122
}
113123
}
@@ -437,6 +447,17 @@ impl EnvelopeError {
437447
}
438448
}
439449

450+
/// One run-level advisory (see [`Envelope::warnings`]). Same `code`/`detail`
451+
/// vocabulary as per-event reasons, but scoped to the whole project/run.
452+
#[derive(Debug, Clone, Serialize)]
453+
#[serde(rename_all = "camelCase")]
454+
pub struct RunWarning {
455+
/// Stable routing tag, e.g. `yarn_classic_berry_migration_risk`.
456+
pub code: String,
457+
/// Human-readable explanation with the suggested remediation.
458+
pub detail: String,
459+
}
460+
440461
// ---------------------------------------------------------------------------
441462
// Tests — pin the JSON serialization shape that downstream consumers see.
442463
// ---------------------------------------------------------------------------

crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,18 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() {
267267
applied.get("errorCode").is_none(),
268268
"clean apply event: {applied}"
269269
);
270+
// Run-level advisory: the fixture has no `packageManager` pin, so the
271+
// wired classic lockfile is one stray `yarn@2+ install` away from being
272+
// silently de-patched — the envelope must say so.
273+
let run_warnings = env["warnings"].as_array().unwrap_or_else(|| {
274+
panic!("wired classic project without a yarn@1 pin must carry run-level warnings: {env}")
275+
});
276+
assert!(
277+
run_warnings
278+
.iter()
279+
.any(|w| w["code"] == "yarn_classic_berry_migration_risk"),
280+
"expected yarn_classic_berry_migration_risk: {env}"
281+
);
270282

271283
// Artifact: deterministic tarball + informational marker in the uuid dir.
272284
let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz");
@@ -426,6 +438,41 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() {
426438
lock_wired,
427439
"re-vendor must leave yarn.lock byte-identical"
428440
);
441+
// The advisory is state-based: an in-sync re-run (wiring still on disk)
442+
// must warn again…
443+
assert!(
444+
env2["warnings"].as_array().is_some_and(|ws| ws
445+
.iter()
446+
.any(|w| w["code"] == "yarn_classic_berry_migration_risk")),
447+
"in-sync re-run must still carry the migration-risk advisory: {env2}"
448+
);
449+
// …and a `packageManager: yarn@1…` pin must silence it (corepack makes
450+
// stray berry installs refuse instead of migrate).
451+
let pkg_path = proj.join("package.json");
452+
let mut pkg: serde_json::Value =
453+
serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap();
454+
pkg["packageManager"] = serde_json::Value::String("yarn@1.22.22".to_string());
455+
std::fs::write(&pkg_path, serde_json::to_string_pretty(&pkg).unwrap()).unwrap();
456+
let (code, stdout, stderr) = run_socket(
457+
&proj,
458+
&[
459+
"vendor",
460+
"--json",
461+
"--offline",
462+
"--cwd",
463+
proj.to_str().unwrap(),
464+
],
465+
);
466+
assert_eq!(
467+
code, 0,
468+
"pinned re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
469+
);
470+
let env3 = parse_envelope(&stdout);
471+
assert!(
472+
env3.get("warnings").is_none(),
473+
"a yarn@1 packageManager pin must suppress the advisory (and empty \
474+
warnings must be omitted from JSON entirely): {env3}"
475+
);
429476

430477
// 6. REVERT PROOF: lock restored byte-for-byte, artifacts gone.
431478
let (code, stdout, stderr) = run_socket(
@@ -446,6 +493,11 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() {
446493
let renv = parse_envelope(&stdout);
447494
assert_eq!(renv["status"], "success", "revert envelope: {renv}");
448495
assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}");
496+
assert!(
497+
renv.get("warnings").is_none(),
498+
"after revert the wiring is gone — the state-based advisory must fall \
499+
silent: {renv}"
500+
);
449501
assert_eq!(
450502
std::fs::read(&lock_path).unwrap(),
451503
lock_before,

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

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,54 @@ impl VendorWarning {
109109
}
110110
}
111111

112+
/// Advisory probe: is this project one `yarn install` away from silently
113+
/// losing its vendored patches?
114+
///
115+
/// Yarn 2+ (berry) migrates a classic (v1) `yarn.lock` to its own format on
116+
/// install and re-resolves every entry from the registry — the vendored
117+
/// `file:./.socket/vendor/…` resolutions are dropped with no warning and the
118+
/// packages install unpatched (observed end-to-end on a real monorepo,
119+
/// 2026-07). Returns the warning when ALL of:
120+
///
121+
/// * `yarn.lock` exists and is classic (`# yarn lockfile v1` marker), AND
122+
/// * it carries vendored wiring (`.socket/vendor/` resolutions), AND
123+
/// * `package.json` does NOT pin yarn classic via `packageManager: yarn@1…`
124+
/// (a corepack pin makes stray berry installs refuse instead of migrate).
125+
///
126+
/// State-based (reads the wired lockfile, not the current run's events), so
127+
/// callers can invoke it unconditionally at envelope-finalize time: it stays
128+
/// silent on unwired projects and after a full revert.
129+
pub fn yarn_classic_berry_migration_risk(project_root: &Path) -> Option<VendorWarning> {
130+
let lock = std::fs::read_to_string(project_root.join("yarn.lock")).ok()?;
131+
if !lock.contains("# yarn lockfile v1") || !lock.contains(".socket/vendor/") {
132+
return None;
133+
}
134+
if let Some(pm) = std::fs::read_to_string(project_root.join("package.json"))
135+
.ok()
136+
.and_then(|pkg| serde_json::from_str::<serde_json::Value>(&pkg).ok())
137+
.and_then(|v| {
138+
v.get("packageManager")
139+
.and_then(|p| p.as_str().map(String::from))
140+
})
141+
{
142+
let major = pm.trim().strip_prefix("yarn@").map(|rest| {
143+
rest.chars()
144+
.take_while(char::is_ascii_digit)
145+
.collect::<String>()
146+
});
147+
if major.as_deref() == Some("1") {
148+
return None;
149+
}
150+
}
151+
Some(VendorWarning::new(
152+
"yarn_classic_berry_migration_risk",
153+
"yarn.lock is yarn-classic (v1) with vendored resolutions: installing with yarn 2+ \
154+
(berry) migrates the lockfile and silently drops them — packages install unpatched \
155+
from the registry. Pin yarn classic (e.g. \"packageManager\": \"yarn@1.22.22\" in \
156+
package.json) so every install uses yarn 1.",
157+
))
158+
}
159+
112160
/// Where `vendor` acquires the installable patched artifact for a package.
113161
///
114162
/// * `Auto` (default) — try the patch.socket.dev vendoring service first and
@@ -997,3 +1045,87 @@ mod harvest_tests {
9971045
);
9981046
}
9991047
}
1048+
1049+
#[cfg(test)]
1050+
mod berry_migration_risk_tests {
1051+
use super::*;
1052+
1053+
const WIRED_V1: &str = "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\
1054+
# yarn lockfile v1\n\n\n\
1055+
left-pad@1.3.0:\n version \"1.3.0\"\n \
1056+
resolved \"file:./.socket/vendor/npm/11111111-2222-4333-8444-555555555555/left-pad-1.3.0.tgz#abc\"\n \
1057+
integrity sha512-x==\n";
1058+
const UNWIRED_V1: &str = "# yarn lockfile v1\n\n\n\
1059+
left-pad@1.3.0:\n version \"1.3.0\"\n \
1060+
resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#abc\"\n";
1061+
1062+
fn project(lock: Option<&str>, package_json: Option<&str>) -> tempfile::TempDir {
1063+
let tmp = tempfile::tempdir().unwrap();
1064+
if let Some(l) = lock {
1065+
std::fs::write(tmp.path().join("yarn.lock"), l).unwrap();
1066+
}
1067+
if let Some(p) = package_json {
1068+
std::fs::write(tmp.path().join("package.json"), p).unwrap();
1069+
}
1070+
tmp
1071+
}
1072+
1073+
#[test]
1074+
fn wired_classic_without_pin_warns() {
1075+
let tmp = project(Some(WIRED_V1), Some(r#"{"name":"x"}"#));
1076+
let w = yarn_classic_berry_migration_risk(tmp.path()).expect("must warn");
1077+
assert_eq!(w.code, "yarn_classic_berry_migration_risk");
1078+
assert!(
1079+
w.detail.contains("yarn 2+"),
1080+
"detail names the trap: {}",
1081+
w.detail
1082+
);
1083+
}
1084+
1085+
#[test]
1086+
fn yarn1_package_manager_pin_suppresses() {
1087+
let tmp = project(
1088+
Some(WIRED_V1),
1089+
Some(r#"{"name":"x","packageManager":"yarn@1.22.22"}"#),
1090+
);
1091+
assert!(yarn_classic_berry_migration_risk(tmp.path()).is_none());
1092+
}
1093+
1094+
#[test]
1095+
fn non_classic_pins_still_warn() {
1096+
// A berry pin does not make a classic lockfile safe — and `yarn@10`
1097+
// must not string-match the `yarn@1` prefix.
1098+
for pm in ["yarn@4.2.0", "yarn@10.0.0", "pnpm@9.0.0"] {
1099+
let pkg = format!(r#"{{"name":"x","packageManager":"{pm}"}}"#);
1100+
let tmp = project(Some(WIRED_V1), Some(&pkg));
1101+
assert!(
1102+
yarn_classic_berry_migration_risk(tmp.path()).is_some(),
1103+
"{pm} must not suppress the warning"
1104+
);
1105+
}
1106+
}
1107+
1108+
#[test]
1109+
fn unwired_or_non_classic_locks_stay_silent() {
1110+
// Registry-only classic lock: no vendored wiring at risk.
1111+
let tmp = project(Some(UNWIRED_V1), Some(r#"{"name":"x"}"#));
1112+
assert!(yarn_classic_berry_migration_risk(tmp.path()).is_none());
1113+
// Berry-format lock (no v1 marker) even with a vendor-ish string.
1114+
let berry = "__metadata:\n version: 8\n\n\"a@npm:1.0.0\":\n resolution: \"a@npm:1.0.0\"\n# .socket/vendor/ mention\n";
1115+
let tmp = project(Some(berry), Some(r#"{"name":"x"}"#));
1116+
assert!(yarn_classic_berry_migration_risk(tmp.path()).is_none());
1117+
// No lockfile at all.
1118+
let tmp = project(None, Some(r#"{"name":"x"}"#));
1119+
assert!(yarn_classic_berry_migration_risk(tmp.path()).is_none());
1120+
}
1121+
1122+
#[test]
1123+
fn malformed_or_missing_package_json_still_warns() {
1124+
// Fail toward warning: an unreadable pin must not silently vouch
1125+
// for the project.
1126+
let tmp = project(Some(WIRED_V1), Some("{not json"));
1127+
assert!(yarn_classic_berry_migration_risk(tmp.path()).is_some());
1128+
let tmp = project(Some(WIRED_V1), None);
1129+
assert!(yarn_classic_berry_migration_risk(tmp.path()).is_some());
1130+
}
1131+
}

0 commit comments

Comments
 (0)