1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
// Copyright 2026 VKrishna04
// SPDX-License-Identifier: Apache-2.0
// Yarn adapter implementation.
use super::{
BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
};
use anyhow::Result;
use std::path::Path;
/// Yarn package manager adapter.
pub struct Yarn;
/// Whether the project is on Yarn Berry (2+) rather than Classic (1.x).
///
/// Berry projects carry a `.yarnrc.yml` or a `.yarn/` directory, and their lockfiles
/// open with a `__metadata:` block that Classic's `# yarn lockfile v1` format never
/// contains. Checked from the project's files rather than `yarn --version`, because the
/// globally installed yarn is routinely Classic while the project pins Berry through
/// Corepack.
fn is_berry_project(project_dir: &Path) -> bool {
if project_dir.join(".yarnrc.yml").exists() || project_dir.join(".yarn").is_dir() {
return true;
}
std::fs::read_to_string(project_dir.join("yarn.lock"))
.map(|c| c.lines().take(30).any(|l| l.starts_with("__metadata:")))
.unwrap_or(false)
}
impl PackageManager for Yarn {
/// Returns the name of the package manager.
fn name(&self) -> &'static str {
"yarn"
}
/// Detects if the project uses yarn by checking for `yarn.lock`.
fn detect(&self, project_dir: &Path) -> bool {
project_dir.join("yarn.lock").exists()
}
/// Returns the bloat directories for yarn (node_modules).
fn bloat_dirs(&self, project_dir: &Path) -> Vec<BloatDir> {
let node_modules = project_dir.join("node_modules");
if node_modules.exists() {
let size = dir_size(&node_modules);
vec![BloatDir {
name: "node_modules".to_string(),
path: node_modules,
size_bytes: size,
shared_bytes: 0,
}]
} else {
vec![]
}
}
/// Enforces the lockfile without installing anything.
///
/// `--mode update-lockfile` resolves `package.json` and writes only `yarn.lock`.
/// It is a Yarn Berry (2+) flag; Yarn Classic rejects it. Classic offers no
/// resolve-only mode at all — its nearest equivalent, `yarn install
/// --frozen-lockfile`, performs a full install and runs every dependency's
/// lifecycle scripts, which is not something to do as a precondition for deleting
/// that same tree. So on Classic an existing `yarn.lock` is itself the proof that
/// `node_modules` is rebuildable, and that is what we require.
///
/// On Berry, `--immutable` is what keeps the resolution read-only: it fails when
/// `yarn.lock` would change rather than writing the change out — and that failure
/// must reach the caller. An earlier version of this method decided Classic-vs-Berry
/// by whether the Berry invocation errored, which swallowed every genuine Berry
/// verification failure and made this the one adapter that could never say no.
fn enforce_lockfile(&self, project_dir: &Path, policy: EnforcePolicy) -> Result<()> {
// detect() required yarn.lock to exist, so on Classic the lockfile-as-proof
// tier is already satisfied.
if !is_berry_project(project_dir) {
return Ok(());
}
enforce_two_tier(
&project_dir.join("yarn.lock"),
"yarn",
&["install", "--immutable", "--mode", "update-lockfile"],
&["install", "--mode", "update-lockfile"],
project_dir,
policy,
)
}
/// Restores the dependencies using the lockfile. The two lines of yarn spell
/// "install exactly what the lockfile says" differently, and each rejects the
/// other's flag.
fn restore(&self, project_dir: &Path, timeout: std::time::Duration) -> Result<()> {
if is_berry_project(project_dir) {
run_command_with_timeout("yarn", &["install", "--immutable"], project_dir, timeout)
} else {
run_command_with_timeout(
"yarn",
&["install", "--frozen-lockfile"],
project_dir,
timeout,
)
}
}
fn lockfiles(&self) -> &'static [&'static str] {
&["yarn.lock"]
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_name() {
assert_eq!(Yarn.name(), "yarn");
}
#[test]
fn test_detect_positive() {
let dir = tempdir().unwrap();
fs::File::create(dir.path().join("yarn.lock")).unwrap();
assert!(Yarn.detect(dir.path()));
}
#[test]
fn test_detect_negative() {
let dir = tempdir().unwrap();
assert!(!Yarn.detect(dir.path()));
}
#[test]
fn test_bloat_dirs_present() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join("node_modules")).unwrap();
let bloat = Yarn.bloat_dirs(dir.path());
assert_eq!(bloat.len(), 1);
assert_eq!(bloat[0].path, dir.path().join("node_modules"));
}
#[test]
fn test_bloat_dirs_absent() {
let dir = tempdir().unwrap();
let bloat = Yarn.bloat_dirs(dir.path());
assert!(bloat.is_empty());
}
#[test]
fn a_classic_lockfile_alone_is_not_berry() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap();
assert!(!is_berry_project(dir.path()));
}
#[test]
fn a_yarnrc_yml_marks_the_project_as_berry() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap();
fs::File::create(dir.path().join(".yarnrc.yml")).unwrap();
assert!(is_berry_project(dir.path()));
}
#[test]
fn a_dot_yarn_directory_marks_the_project_as_berry() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap();
fs::create_dir(dir.path().join(".yarn")).unwrap();
assert!(is_berry_project(dir.path()));
}
#[test]
fn a_metadata_block_in_the_lockfile_marks_the_project_as_berry() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("yarn.lock"), "__metadata:\n version: 8\n").unwrap();
assert!(is_berry_project(dir.path()));
}
// On Classic the lockfile's existence is the whole proof — no yarn binary runs, so
// this passes on a machine with no yarn at all. Berry is the branch that shells
// out, and the one whose failures must reach the caller (the old version swallowed
// them by treating any Berry error as "must be Classic then").
#[test]
fn enforce_on_classic_needs_no_yarn_binary() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("yarn.lock"),
"# yarn lockfile v1\n\nleft-pad@^1.3.0:\n version \"1.3.0\"\n",
)
.unwrap();
assert!(
Yarn.enforce_lockfile(dir.path(), EnforcePolicy::default())
.is_ok()
);
}
}