Skip to main content

dev_prune/adapters/
npm.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// NPM adapter implementation.
5
6use 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
13/// NPM package manager adapter.
14pub struct Npm;
15
16/// Refuse when `node_modules` holds packages `package-lock.json` never recorded.
17///
18/// `npm install --no-save` puts a package in the tree without touching the lockfile,
19/// and `npm link` plants a symlink to a package that lives outside the project. Both
20/// survive `npm ci --dry-run` — it checks the lockfile against `package.json`, not
21/// against the tree — and neither comes back after deletion. npm writes its own record
22/// of what it actually installed to `node_modules/.package-lock.json`; comparing that
23/// against the real lockfile catches the `--no-save` case, and a scan for symlinked
24/// entries catches `npm link`.
25fn check_unrecorded_installs(project_dir: &Path) -> Result<()> {
26    let node_modules = project_dir.join("node_modules");
27
28    // `npm link` first: a symlinked package is outside the tree entirely, so the
29    // hidden lockfile comparison below would not see it. Dot-entries are skipped —
30    // `.bin` is symlinks by design.
31    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                // Scoped packages sit one level down: `@scope/pkg`.
47                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
92/// The `--no-save` case, as data: entries npm's own install record
93/// (`node_modules/.package-lock.json`) knows about that the committed lockfile does
94/// not, sorted. Either file missing or unparseable means there is nothing to compare —
95/// not evidence of drift — and answers empty.
96fn 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    /// Returns the name of the package manager.
121    fn name(&self) -> &'static str {
122        "npm"
123    }
124
125    /// Detects if the project uses npm by checking for `package-lock.json`.
126    fn detect(&self, project_dir: &Path) -> bool {
127        project_dir.join("package-lock.json").exists()
128    }
129
130    /// Returns the bloat directories for npm (node_modules).
131    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    /// Enforces the lockfile without running install scripts, and without writing.
147    ///
148    /// `npm ci --dry-run` is the read-only check: it builds the tree the lockfile
149    /// describes, fails outright when `package-lock.json` and `package.json` disagree,
150    /// and — with `--dry-run` — neither installs nor writes. `--package-lock-only` is
151    /// the writing form, kept for the no-lockfile case where there is nothing to
152    /// preserve, and for the user who opted into rewriting.
153    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    /// Restores the dependencies using the lockfile.
167    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    /// The `--no-save` comparison `enforce_lockfile` refuses on, as data. npm-linked
176    /// packages are deliberately not listed here: a symlink to code outside the project
177    /// is not something any lockfile edit can record, so it stays a prune-time refusal.
178    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    /// npm used to run `npm install --package-lock-only` here, which *fixes* a lockfile
203    /// that has drifted from `package.json` by rewriting it — during a pass that may
204    /// have been started by the scheduler. Verification must now refuse instead.
205    ///
206    /// Skipped rather than failed when `npm` is absent from `PATH`.
207    #[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        // A lockfile that never heard of `left-pad`, so it cannot rebuild the tree.
220        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    /// A missing hidden lockfile means npm never recorded what it installed — that is
288    /// "nothing to compare", not drift.
289    #[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}