dev_prune/adapters/
npm.rs1use super::{
7 BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
8};
9use anyhow::Result;
10use std::fs;
11use std::path::Path;
12
13pub struct Npm;
15
16fn check_unrecorded_installs(project_dir: &Path) -> Result<()> {
26 let node_modules = project_dir.join("node_modules");
27
28 let mut linked: Vec<String> = Vec::new();
32 if let Ok(entries) = fs::read_dir(&node_modules) {
33 for entry in entries.flatten() {
34 let name = entry.file_name().to_string_lossy().into_owned();
35 if name.starts_with('.') {
36 continue;
37 }
38 let is_link = |p: &Path| {
39 fs::symlink_metadata(p)
40 .map(|m| m.file_type().is_symlink())
41 .unwrap_or(false)
42 };
43 if is_link(&entry.path()) {
44 linked.push(name);
45 } else if name.starts_with('@') {
46 if let Ok(scoped) = fs::read_dir(entry.path()) {
48 for pkg in scoped.flatten() {
49 if is_link(&pkg.path()) {
50 linked.push(format!("{name}/{}", pkg.file_name().to_string_lossy()));
51 }
52 }
53 }
54 }
55 }
56 }
57 if !linked.is_empty() {
58 linked.sort();
59 anyhow::bail!(
60 "`{}` contains npm-linked package(s) ({}) — symlinks to code that lives \
61 outside this project. `npm ci` after deletion would not re-link them. \
62 Run `npm unlink` for each, or install them normally, then retry.",
63 node_modules.display(),
64 linked.join(", ")
65 );
66 }
67
68 let extras = no_save_extras(project_dir);
69 if extras.is_empty() {
70 return Ok(());
71 }
72 let shown = extras
73 .iter()
74 .take(10)
75 .map(|s| s.as_str())
76 .collect::<Vec<_>>()
77 .join(", ");
78 let suffix = if extras.len() > 10 {
79 format!(", … and {} more", extras.len() - 10)
80 } else {
81 String::new()
82 };
83 anyhow::bail!(
84 "`node_modules` holds {} package(s) that package-lock.json does not record \
85 ({shown}{suffix}) — likely installed with `npm install --no-save`. `npm ci` \
86 after deletion would not bring them back. Run `npm install <pkg>` to save \
87 them (or `npm install` to sync), then retry.",
88 extras.len()
89 );
90}
91
92fn no_save_extras(project_dir: &Path) -> Vec<String> {
97 let package_names = |path: &Path| -> Option<std::collections::HashSet<String>> {
98 let json: serde_json::Value = serde_json::from_str(&fs::read_to_string(path).ok()?).ok()?;
99 Some(
100 json.get("packages")?
101 .as_object()?
102 .keys()
103 .filter(|k| !k.is_empty())
104 .cloned()
105 .collect(),
106 )
107 };
108 let (Some(installed), Some(recorded)) = (
109 package_names(&project_dir.join("node_modules").join(".package-lock.json")),
110 package_names(&project_dir.join("package-lock.json")),
111 ) else {
112 return Vec::new();
113 };
114 let mut extras: Vec<String> = installed.difference(&recorded).cloned().collect();
115 extras.sort();
116 extras
117}
118
119impl PackageManager for Npm {
120 fn name(&self) -> &'static str {
122 "npm"
123 }
124
125 fn detect(&self, project_dir: &Path) -> bool {
127 project_dir.join("package-lock.json").exists()
128 }
129
130 fn bloat_dirs(&self, project_dir: &Path) -> Vec<BloatDir> {
132 let node_modules = project_dir.join("node_modules");
133 if node_modules.exists() {
134 let size = dir_size(&node_modules);
135 vec![BloatDir {
136 name: "node_modules".to_string(),
137 path: node_modules,
138 size_bytes: size,
139 shared_bytes: 0,
140 }]
141 } else {
142 vec![]
143 }
144 }
145
146 fn enforce_lockfile(&self, project_dir: &Path, policy: EnforcePolicy) -> Result<()> {
154 check_unrecorded_installs(project_dir)?;
155 let lockfile = project_dir.join("package-lock.json");
156 enforce_two_tier(
157 &lockfile,
158 "npm",
159 &["ci", "--dry-run", "--ignore-scripts"],
160 &["install", "--package-lock-only", "--ignore-scripts"],
161 project_dir,
162 policy,
163 )
164 }
165
166 fn restore(&self, project_dir: &Path, timeout: std::time::Duration) -> Result<()> {
168 run_command_with_timeout("npm", &["ci"], project_dir, timeout)
169 }
170
171 fn lockfiles(&self) -> &'static [&'static str] {
172 &["package-lock.json"]
173 }
174
175 fn drift(&self, project_dir: &Path) -> Vec<super::DriftReport> {
179 let extras = no_save_extras(project_dir);
180 if extras.is_empty() {
181 return Vec::new();
182 }
183 vec![super::DriftReport {
184 directory: "node_modules".to_string(),
185 unrecorded: extras,
186 record_command: "npm install <pkg> (or `npm install` to sync the lockfile)",
187 }]
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use std::fs;
195 use tempfile::tempdir;
196
197 #[test]
198 fn test_name() {
199 assert_eq!(Npm.name(), "npm");
200 }
201
202 #[test]
208 fn a_default_pass_never_rewrites_a_stale_lockfile() {
209 if !super::super::binary_available("npm") {
210 return;
211 }
212 let dir = tempdir().unwrap();
213 fs::write(
214 dir.path().join("package.json"),
215 r#"{"name":"stale","version":"1.0.0","dependencies":{"left-pad":"^1.3.0"}}"#,
216 )
217 .unwrap();
218
219 let stale = r#"{"name":"stale","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"stale","version":"1.0.0"}}}"#;
221 fs::write(dir.path().join("package-lock.json"), stale).unwrap();
222
223 let result = Npm.enforce_lockfile(dir.path(), EnforcePolicy::default());
224
225 assert!(
226 result.is_err(),
227 "a lockfile out of sync with package.json must not pass verification"
228 );
229 assert_eq!(
230 fs::read_to_string(dir.path().join("package-lock.json")).unwrap(),
231 stale,
232 "the read-only verification rewrote package-lock.json"
233 );
234 }
235
236 #[test]
237 fn test_detect_positive() {
238 let dir = tempdir().unwrap();
239 fs::File::create(dir.path().join("package-lock.json")).unwrap();
240 assert!(Npm.detect(dir.path()));
241 }
242
243 #[test]
244 fn test_detect_negative() {
245 let dir = tempdir().unwrap();
246 assert!(!Npm.detect(dir.path()));
247 }
248
249 #[test]
250 fn test_bloat_dirs_present() {
251 let dir = tempdir().unwrap();
252 fs::create_dir(dir.path().join("node_modules")).unwrap();
253 let bloat = Npm.bloat_dirs(dir.path());
254 assert_eq!(bloat.len(), 1);
255 assert_eq!(bloat[0].path, dir.path().join("node_modules"));
256 }
257
258 #[test]
259 fn test_bloat_dirs_absent() {
260 let dir = tempdir().unwrap();
261 let bloat = Npm.bloat_dirs(dir.path());
262 assert!(bloat.is_empty());
263 }
264
265 #[test]
266 fn drift_reports_the_no_save_install_as_data() {
267 let dir = tempdir().unwrap();
268 fs::write(
269 dir.path().join("package-lock.json"),
270 r#"{"packages":{"":{},"node_modules/left-pad":{}}}"#,
271 )
272 .unwrap();
273 let nm = dir.path().join("node_modules");
274 fs::create_dir(&nm).unwrap();
275 fs::write(
276 nm.join(".package-lock.json"),
277 r#"{"packages":{"":{},"node_modules/left-pad":{},"node_modules/sneaky":{}}}"#,
278 )
279 .unwrap();
280
281 let reports = Npm.drift(dir.path());
282 assert_eq!(reports.len(), 1);
283 assert_eq!(reports[0].directory, "node_modules");
284 assert_eq!(reports[0].unrecorded, vec!["node_modules/sneaky"]);
285 }
286
287 #[test]
290 fn drift_is_silent_without_npms_own_install_record() {
291 let dir = tempdir().unwrap();
292 fs::write(
293 dir.path().join("package-lock.json"),
294 r#"{"packages":{"":{}}}"#,
295 )
296 .unwrap();
297 fs::create_dir(dir.path().join("node_modules")).unwrap();
298
299 assert!(Npm.drift(dir.path()).is_empty());
300 }
301}