use super::{
BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
};
use anyhow::Result;
use std::fs;
use std::path::Path;
pub struct Npm;
fn check_unrecorded_installs(project_dir: &Path) -> Result<()> {
let node_modules = project_dir.join("node_modules");
let mut linked: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&node_modules) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let is_link = |p: &Path| {
fs::symlink_metadata(p)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
};
if is_link(&entry.path()) {
linked.push(name);
} else if name.starts_with('@') {
if let Ok(scoped) = fs::read_dir(entry.path()) {
for pkg in scoped.flatten() {
if is_link(&pkg.path()) {
linked.push(format!("{name}/{}", pkg.file_name().to_string_lossy()));
}
}
}
}
}
}
if !linked.is_empty() {
linked.sort();
anyhow::bail!(
"`{}` contains npm-linked package(s) ({}) — symlinks to code that lives \
outside this project. `npm ci` after deletion would not re-link them. \
Run `npm unlink` for each, or install them normally, then retry.",
node_modules.display(),
linked.join(", ")
);
}
let extras = no_save_extras(project_dir);
if extras.is_empty() {
return Ok(());
}
let shown = extras
.iter()
.take(10)
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ");
let suffix = if extras.len() > 10 {
format!(", … and {} more", extras.len() - 10)
} else {
String::new()
};
anyhow::bail!(
"`node_modules` holds {} package(s) that package-lock.json does not record \
({shown}{suffix}) — likely installed with `npm install --no-save`. `npm ci` \
after deletion would not bring them back. Run `npm install <pkg>` to save \
them (or `npm install` to sync), then retry.",
extras.len()
);
}
fn no_save_extras(project_dir: &Path) -> Vec<String> {
let package_names = |path: &Path| -> Option<std::collections::HashSet<String>> {
let json: serde_json::Value = serde_json::from_str(&fs::read_to_string(path).ok()?).ok()?;
Some(
json.get("packages")?
.as_object()?
.keys()
.filter(|k| !k.is_empty())
.cloned()
.collect(),
)
};
let (Some(installed), Some(recorded)) = (
package_names(&project_dir.join("node_modules").join(".package-lock.json")),
package_names(&project_dir.join("package-lock.json")),
) else {
return Vec::new();
};
let mut extras: Vec<String> = installed.difference(&recorded).cloned().collect();
extras.sort();
extras
}
impl PackageManager for Npm {
fn name(&self) -> &'static str {
"npm"
}
fn detect(&self, project_dir: &Path) -> bool {
project_dir.join("package-lock.json").exists()
}
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![]
}
}
fn enforce_lockfile(&self, project_dir: &Path, policy: EnforcePolicy) -> Result<()> {
check_unrecorded_installs(project_dir)?;
let lockfile = project_dir.join("package-lock.json");
enforce_two_tier(
&lockfile,
"npm",
&["ci", "--dry-run", "--ignore-scripts"],
&["install", "--package-lock-only", "--ignore-scripts"],
project_dir,
policy,
)
}
fn restore(&self, project_dir: &Path, timeout: std::time::Duration) -> Result<()> {
run_command_with_timeout("npm", &["ci"], project_dir, timeout)
}
fn lockfiles(&self) -> &'static [&'static str] {
&["package-lock.json"]
}
fn drift(&self, project_dir: &Path) -> Vec<super::DriftReport> {
let extras = no_save_extras(project_dir);
if extras.is_empty() {
return Vec::new();
}
vec![super::DriftReport {
directory: "node_modules".to_string(),
unrecorded: extras,
record_command: "npm install <pkg> (or `npm install` to sync the lockfile)",
}]
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_name() {
assert_eq!(Npm.name(), "npm");
}
#[test]
fn a_default_pass_never_rewrites_a_stale_lockfile() {
if !super::super::binary_available("npm") {
return;
}
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"name":"stale","version":"1.0.0","dependencies":{"left-pad":"^1.3.0"}}"#,
)
.unwrap();
let stale = r#"{"name":"stale","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"stale","version":"1.0.0"}}}"#;
fs::write(dir.path().join("package-lock.json"), stale).unwrap();
let result = Npm.enforce_lockfile(dir.path(), EnforcePolicy::default());
assert!(
result.is_err(),
"a lockfile out of sync with package.json must not pass verification"
);
assert_eq!(
fs::read_to_string(dir.path().join("package-lock.json")).unwrap(),
stale,
"the read-only verification rewrote package-lock.json"
);
}
#[test]
fn test_detect_positive() {
let dir = tempdir().unwrap();
fs::File::create(dir.path().join("package-lock.json")).unwrap();
assert!(Npm.detect(dir.path()));
}
#[test]
fn test_detect_negative() {
let dir = tempdir().unwrap();
assert!(!Npm.detect(dir.path()));
}
#[test]
fn test_bloat_dirs_present() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join("node_modules")).unwrap();
let bloat = Npm.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 = Npm.bloat_dirs(dir.path());
assert!(bloat.is_empty());
}
#[test]
fn drift_reports_the_no_save_install_as_data() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package-lock.json"),
r#"{"packages":{"":{},"node_modules/left-pad":{}}}"#,
)
.unwrap();
let nm = dir.path().join("node_modules");
fs::create_dir(&nm).unwrap();
fs::write(
nm.join(".package-lock.json"),
r#"{"packages":{"":{},"node_modules/left-pad":{},"node_modules/sneaky":{}}}"#,
)
.unwrap();
let reports = Npm.drift(dir.path());
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].directory, "node_modules");
assert_eq!(reports[0].unrecorded, vec!["node_modules/sneaky"]);
}
#[test]
fn drift_is_silent_without_npms_own_install_record() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package-lock.json"),
r#"{"packages":{"":{}}}"#,
)
.unwrap();
fs::create_dir(dir.path().join("node_modules")).unwrap();
assert!(Npm.drift(dir.path()).is_empty());
}
}