use super::{
BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
};
use crate::declared::{Gap, RebuildCheck, label_of, path_of, relative_parts};
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)",
}]
}
}
pub(crate) struct NodeScripts;
impl RebuildCheck for NodeScripts {
fn tools(&self) -> &'static [&'static str] {
&["npm", "pnpm", "yarn"]
}
fn gap(&self, repo_path: &Path, tool: &str, args: &[&str]) -> Option<Gap> {
node_script_gap(repo_path, tool, args)
}
}
const NODE_SUBCOMMANDS: &[&str] = &[
"add",
"audit",
"bin",
"cache",
"config",
"create",
"dedupe",
"deploy",
"dlx",
"doctor",
"env",
"exec",
"fetch",
"get",
"global",
"help",
"i",
"import",
"info",
"init",
"install",
"licenses",
"link",
"list",
"login",
"logout",
"ls",
"node",
"outdated",
"pack",
"patch",
"policies",
"prune",
"publish",
"rebuild",
"remove",
"restart",
"rm",
"root",
"server",
"set",
"setup",
"start",
"stop",
"store",
"test",
"un",
"uninstall",
"unlink",
"up",
"update",
"upgrade",
"version",
"whoami",
"why",
"workspace",
"workspaces",
];
fn node_script_gap(repo_path: &Path, tool: &str, args: &[&str]) -> Option<Gap> {
let (script, prefix) = node_script_and_prefix(tool, args)?;
let parts = relative_parts(prefix.as_deref())?;
let manifest = label_of(&parts, "package.json");
let content = fs::read_to_string(path_of(repo_path, &parts, "package.json")).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
let defined = match json.get("scripts") {
None => false,
Some(value) => value.as_object()?.contains_key(script.as_str()),
};
if defined {
return None;
}
Some(Gap {
what: format!("`{manifest}` defines no `{script}` script"),
fix: format!("Add a `{script}` script to `{manifest}`, or fix the command."),
})
}
fn node_script_and_prefix(tool: &str, args: &[&str]) -> Option<(String, Option<String>)> {
let mut prefix = None;
let mut saw_run = false;
let mut i = 0;
while i < args.len() {
let arg = args[i];
if arg == "--" {
return None;
}
if let Some(value) = arg
.strip_prefix("--prefix=")
.or_else(|| arg.strip_prefix("--dir="))
.or_else(|| arg.strip_prefix("--cwd="))
{
prefix = Some(value.to_string());
} else if matches!(arg, "--prefix" | "--dir" | "-C" | "--cwd") {
prefix = Some((*args.get(i + 1)?).to_string());
i += 1;
} else if arg == "run" || arg == "run-script" {
saw_run = true;
} else if arg.starts_with('-') {
return None;
} else if saw_run {
return Some((arg.to_string(), prefix));
} else if tool == "npm" || NODE_SUBCOMMANDS.contains(&arg) {
return None;
} else {
return Some((arg.to_string(), prefix));
}
i += 1;
}
None
}
#[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());
}
}