1use super::{
7 BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
8};
9use crate::declared::{Gap, RebuildCheck, label_of, path_of, relative_parts};
10use anyhow::Result;
11use std::fs;
12use std::path::Path;
13
14pub struct Npm;
16
17fn check_unrecorded_installs(project_dir: &Path) -> Result<()> {
27 let node_modules = project_dir.join("node_modules");
28
29 let mut linked: Vec<String> = Vec::new();
33 if let Ok(entries) = fs::read_dir(&node_modules) {
34 for entry in entries.flatten() {
35 let name = entry.file_name().to_string_lossy().into_owned();
36 if name.starts_with('.') {
37 continue;
38 }
39 let is_link = |p: &Path| {
40 fs::symlink_metadata(p)
41 .map(|m| m.file_type().is_symlink())
42 .unwrap_or(false)
43 };
44 if is_link(&entry.path()) {
45 linked.push(name);
46 } else if name.starts_with('@') {
47 if let Ok(scoped) = fs::read_dir(entry.path()) {
49 for pkg in scoped.flatten() {
50 if is_link(&pkg.path()) {
51 linked.push(format!("{name}/{}", pkg.file_name().to_string_lossy()));
52 }
53 }
54 }
55 }
56 }
57 }
58 if !linked.is_empty() {
59 linked.sort();
60 anyhow::bail!(
61 "`{}` contains npm-linked package(s) ({}) — symlinks to code that lives \
62 outside this project. `npm ci` after deletion would not re-link them. \
63 Run `npm unlink` for each, or install them normally, then retry.",
64 node_modules.display(),
65 linked.join(", ")
66 );
67 }
68
69 let extras = no_save_extras(project_dir);
70 if extras.is_empty() {
71 return Ok(());
72 }
73 let shown = extras
74 .iter()
75 .take(10)
76 .map(|s| s.as_str())
77 .collect::<Vec<_>>()
78 .join(", ");
79 let suffix = if extras.len() > 10 {
80 format!(", … and {} more", extras.len() - 10)
81 } else {
82 String::new()
83 };
84 anyhow::bail!(
85 "`node_modules` holds {} package(s) that package-lock.json does not record \
86 ({shown}{suffix}) — likely installed with `npm install --no-save`. `npm ci` \
87 after deletion would not bring them back. Run `npm install <pkg>` to save \
88 them (or `npm install` to sync), then retry.",
89 extras.len()
90 );
91}
92
93fn no_save_extras(project_dir: &Path) -> Vec<String> {
98 let package_names = |path: &Path| -> Option<std::collections::HashSet<String>> {
99 let json: serde_json::Value = serde_json::from_str(&fs::read_to_string(path).ok()?).ok()?;
100 Some(
101 json.get("packages")?
102 .as_object()?
103 .keys()
104 .filter(|k| !k.is_empty())
105 .cloned()
106 .collect(),
107 )
108 };
109 let (Some(installed), Some(recorded)) = (
110 package_names(&project_dir.join("node_modules").join(".package-lock.json")),
111 package_names(&project_dir.join("package-lock.json")),
112 ) else {
113 return Vec::new();
114 };
115 let mut extras: Vec<String> = installed.difference(&recorded).cloned().collect();
116 extras.sort();
117 extras
118}
119
120impl PackageManager for Npm {
121 fn name(&self) -> &'static str {
123 "npm"
124 }
125
126 fn detect(&self, project_dir: &Path) -> bool {
128 project_dir.join("package-lock.json").exists()
129 }
130
131 fn bloat_dirs(&self, project_dir: &Path) -> Vec<BloatDir> {
133 let node_modules = project_dir.join("node_modules");
134 if node_modules.exists() {
135 let size = dir_size(&node_modules);
136 vec![BloatDir {
137 name: "node_modules".to_string(),
138 path: node_modules,
139 size_bytes: size,
140 shared_bytes: 0,
141 }]
142 } else {
143 vec![]
144 }
145 }
146
147 fn enforce_lockfile(&self, project_dir: &Path, policy: EnforcePolicy) -> Result<()> {
155 check_unrecorded_installs(project_dir)?;
156 let lockfile = project_dir.join("package-lock.json");
157 enforce_two_tier(
158 &lockfile,
159 "npm",
160 &["ci", "--dry-run", "--ignore-scripts"],
161 &["install", "--package-lock-only", "--ignore-scripts"],
162 project_dir,
163 policy,
164 )
165 }
166
167 fn restore(&self, project_dir: &Path, timeout: std::time::Duration) -> Result<()> {
169 run_command_with_timeout("npm", &["ci"], project_dir, timeout)
170 }
171
172 fn lockfiles(&self) -> &'static [&'static str] {
173 &["package-lock.json"]
174 }
175
176 fn drift(&self, project_dir: &Path) -> Vec<super::DriftReport> {
180 let extras = no_save_extras(project_dir);
181 if extras.is_empty() {
182 return Vec::new();
183 }
184 vec![super::DriftReport {
185 directory: "node_modules".to_string(),
186 unrecorded: extras,
187 record_command: "npm install <pkg> (or `npm install` to sync the lockfile)",
188 }]
189 }
190}
191
192pub(crate) struct NodeScripts;
199
200impl RebuildCheck for NodeScripts {
201 fn tools(&self) -> &'static [&'static str] {
202 &["npm", "pnpm", "yarn"]
203 }
204
205 fn gap(&self, repo_path: &Path, tool: &str, args: &[&str]) -> Option<Gap> {
206 node_script_gap(repo_path, tool, args)
207 }
208}
209
210const NODE_SUBCOMMANDS: &[&str] = &[
217 "add",
218 "audit",
219 "bin",
220 "cache",
221 "config",
222 "create",
223 "dedupe",
224 "deploy",
225 "dlx",
226 "doctor",
227 "env",
228 "exec",
229 "fetch",
230 "get",
231 "global",
232 "help",
233 "i",
234 "import",
235 "info",
236 "init",
237 "install",
238 "licenses",
239 "link",
240 "list",
241 "login",
242 "logout",
243 "ls",
244 "node",
245 "outdated",
246 "pack",
247 "patch",
248 "policies",
249 "prune",
250 "publish",
251 "rebuild",
252 "remove",
253 "restart",
254 "rm",
255 "root",
256 "server",
257 "set",
258 "setup",
259 "start",
260 "stop",
261 "store",
262 "test",
263 "un",
264 "uninstall",
265 "unlink",
266 "up",
267 "update",
268 "upgrade",
269 "version",
270 "whoami",
271 "why",
272 "workspace",
273 "workspaces",
274];
275
276fn node_script_gap(repo_path: &Path, tool: &str, args: &[&str]) -> Option<Gap> {
278 let (script, prefix) = node_script_and_prefix(tool, args)?;
279 let parts = relative_parts(prefix.as_deref())?;
280 let manifest = label_of(&parts, "package.json");
281 let content = fs::read_to_string(path_of(repo_path, &parts, "package.json")).ok()?;
282 let json: serde_json::Value = serde_json::from_str(&content).ok()?;
283 let defined = match json.get("scripts") {
284 None => false,
286 Some(value) => value.as_object()?.contains_key(script.as_str()),
288 };
289 if defined {
290 return None;
291 }
292 Some(Gap {
293 what: format!("`{manifest}` defines no `{script}` script"),
294 fix: format!("Add a `{script}` script to `{manifest}`, or fix the command."),
295 })
296}
297
298fn node_script_and_prefix(tool: &str, args: &[&str]) -> Option<(String, Option<String>)> {
306 let mut prefix = None;
307 let mut saw_run = false;
308 let mut i = 0;
309 while i < args.len() {
310 let arg = args[i];
311 if arg == "--" {
312 return None;
313 }
314 if let Some(value) = arg
315 .strip_prefix("--prefix=")
316 .or_else(|| arg.strip_prefix("--dir="))
317 .or_else(|| arg.strip_prefix("--cwd="))
318 {
319 prefix = Some(value.to_string());
320 } else if matches!(arg, "--prefix" | "--dir" | "-C" | "--cwd") {
321 prefix = Some((*args.get(i + 1)?).to_string());
322 i += 1;
323 } else if arg == "run" || arg == "run-script" {
324 saw_run = true;
325 } else if arg.starts_with('-') {
326 return None;
329 } else if saw_run {
330 return Some((arg.to_string(), prefix));
331 } else if tool == "npm" || NODE_SUBCOMMANDS.contains(&arg) {
332 return None;
335 } else {
336 return Some((arg.to_string(), prefix));
337 }
338 i += 1;
339 }
340 None
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use std::fs;
347 use tempfile::tempdir;
348
349 #[test]
350 fn test_name() {
351 assert_eq!(Npm.name(), "npm");
352 }
353
354 #[test]
360 fn a_default_pass_never_rewrites_a_stale_lockfile() {
361 if !super::super::binary_available("npm") {
362 return;
363 }
364 let dir = tempdir().unwrap();
365 fs::write(
366 dir.path().join("package.json"),
367 r#"{"name":"stale","version":"1.0.0","dependencies":{"left-pad":"^1.3.0"}}"#,
368 )
369 .unwrap();
370
371 let stale = r#"{"name":"stale","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"stale","version":"1.0.0"}}}"#;
373 fs::write(dir.path().join("package-lock.json"), stale).unwrap();
374
375 let result = Npm.enforce_lockfile(dir.path(), EnforcePolicy::default());
376
377 assert!(
378 result.is_err(),
379 "a lockfile out of sync with package.json must not pass verification"
380 );
381 assert_eq!(
382 fs::read_to_string(dir.path().join("package-lock.json")).unwrap(),
383 stale,
384 "the read-only verification rewrote package-lock.json"
385 );
386 }
387
388 #[test]
389 fn test_detect_positive() {
390 let dir = tempdir().unwrap();
391 fs::File::create(dir.path().join("package-lock.json")).unwrap();
392 assert!(Npm.detect(dir.path()));
393 }
394
395 #[test]
396 fn test_detect_negative() {
397 let dir = tempdir().unwrap();
398 assert!(!Npm.detect(dir.path()));
399 }
400
401 #[test]
402 fn test_bloat_dirs_present() {
403 let dir = tempdir().unwrap();
404 fs::create_dir(dir.path().join("node_modules")).unwrap();
405 let bloat = Npm.bloat_dirs(dir.path());
406 assert_eq!(bloat.len(), 1);
407 assert_eq!(bloat[0].path, dir.path().join("node_modules"));
408 }
409
410 #[test]
411 fn test_bloat_dirs_absent() {
412 let dir = tempdir().unwrap();
413 let bloat = Npm.bloat_dirs(dir.path());
414 assert!(bloat.is_empty());
415 }
416
417 #[test]
418 fn drift_reports_the_no_save_install_as_data() {
419 let dir = tempdir().unwrap();
420 fs::write(
421 dir.path().join("package-lock.json"),
422 r#"{"packages":{"":{},"node_modules/left-pad":{}}}"#,
423 )
424 .unwrap();
425 let nm = dir.path().join("node_modules");
426 fs::create_dir(&nm).unwrap();
427 fs::write(
428 nm.join(".package-lock.json"),
429 r#"{"packages":{"":{},"node_modules/left-pad":{},"node_modules/sneaky":{}}}"#,
430 )
431 .unwrap();
432
433 let reports = Npm.drift(dir.path());
434 assert_eq!(reports.len(), 1);
435 assert_eq!(reports[0].directory, "node_modules");
436 assert_eq!(reports[0].unrecorded, vec!["node_modules/sneaky"]);
437 }
438
439 #[test]
442 fn drift_is_silent_without_npms_own_install_record() {
443 let dir = tempdir().unwrap();
444 fs::write(
445 dir.path().join("package-lock.json"),
446 r#"{"packages":{"":{}}}"#,
447 )
448 .unwrap();
449 fs::create_dir(dir.path().join("node_modules")).unwrap();
450
451 assert!(Npm.drift(dir.path()).is_empty());
452 }
453}