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 crate::declared::{Gap, RebuildCheck, label_of, path_of, relative_parts};
10use anyhow::Result;
11use std::fs;
12use std::path::Path;
13
14/// NPM package manager adapter.
15pub struct Npm;
16
17/// Refuse when `node_modules` holds packages `package-lock.json` never recorded.
18///
19/// `npm install --no-save` puts a package in the tree without touching the lockfile,
20/// and `npm link` plants a symlink to a package that lives outside the project. Both
21/// survive `npm ci --dry-run` — it checks the lockfile against `package.json`, not
22/// against the tree — and neither comes back after deletion. npm writes its own record
23/// of what it actually installed to `node_modules/.package-lock.json`; comparing that
24/// against the real lockfile catches the `--no-save` case, and a scan for symlinked
25/// entries catches `npm link`.
26fn check_unrecorded_installs(project_dir: &Path) -> Result<()> {
27    let node_modules = project_dir.join("node_modules");
28
29    // `npm link` first: a symlinked package is outside the tree entirely, so the
30    // hidden lockfile comparison below would not see it. Dot-entries are skipped —
31    // `.bin` is symlinks by design.
32    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                // Scoped packages sit one level down: `@scope/pkg`.
48                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
93/// The `--no-save` case, as data: entries npm's own install record
94/// (`node_modules/.package-lock.json`) knows about that the committed lockfile does
95/// not, sorted. Either file missing or unparseable means there is nothing to compare —
96/// not evidence of drift — and answers empty.
97fn 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    /// Returns the name of the package manager.
122    fn name(&self) -> &'static str {
123        "npm"
124    }
125
126    /// Detects if the project uses npm by checking for `package-lock.json`.
127    fn detect(&self, project_dir: &Path) -> bool {
128        project_dir.join("package-lock.json").exists()
129    }
130
131    /// Returns the bloat directories for npm (node_modules).
132    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    /// Enforces the lockfile without running install scripts, and without writing.
148    ///
149    /// `npm ci --dry-run` is the read-only check: it builds the tree the lockfile
150    /// describes, fails outright when `package-lock.json` and `package.json` disagree,
151    /// and — with `--dry-run` — neither installs nor writes. `--package-lock-only` is
152    /// the writing form, kept for the no-lockfile case where there is nothing to
153    /// preserve, and for the user who opted into rewriting.
154    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    /// Restores the dependencies using the lockfile.
168    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    /// The `--no-save` comparison `enforce_lockfile` refuses on, as data. npm-linked
177    /// packages are deliberately not listed here: a symlink to code outside the project
178    /// is not something any lockfile edit can record, so it stays a prune-time refusal.
179    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
192/// The rebuild check for `npm`, `pnpm` and `yarn` declarations.
193///
194/// One check for three tools because all three resolve a script the same way, against
195/// the same manifest: `package.json` in the directory the command names. The check
196/// lives here rather than in `crate::declared` so everything this crate knows about
197/// node package managers stays in one file.
198pub(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
210/// Subcommands of `pnpm` and `yarn`, which both also run a script from a bare word.
211///
212/// `pnpm build` runs the `build` script, but `pnpm install` does not — so the bare form
213/// can only be resolved against a list of the tool's own verbs. Deliberately generous,
214/// and shared between the two tools: a word wrongly on this list is a script this
215/// check declines to look up, which is the failure this file prefers.
216const 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
276/// A `package.json` script the command names and the file does not define.
277fn 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        // No `scripts` table at all: there is nothing `run` could resolve against.
285        None => false,
286        // Present but not a table — a shape this cannot read.
287        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
298/// The script an `npm`/`pnpm`/`yarn` command runs, and the directory it runs it in.
299///
300/// The prefix flags matter because `crate::declared`'s shell-builtin refusal actively
301/// recommends `npm --prefix docs run build`: following that advice must not then land
302/// on the wrong `package.json`. Resolution stops at the named directory rather than
303/// walking upward the way npm does — a parent manifest could be outside the repository,
304/// and "somewhere above here" is not an answer this check is willing to refuse on.
305fn 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            // A flag this does not model. Whatever follows it might be its argument, and
327            // reading that as the script name is exactly the guess to avoid.
328            return None;
329        } else if saw_run {
330            return Some((arg.to_string(), prefix));
331        } else if tool == "npm" || NODE_SUBCOMMANDS.contains(&arg) {
332            // npm has no bare-script shorthand, and the pnpm/yarn one does not apply to
333            // the tool's own verbs.
334            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    /// npm used to run `npm install --package-lock-only` here, which *fixes* a lockfile
355    /// that has drifted from `package.json` by rewriting it — during a pass that may
356    /// have been started by the scheduler. Verification must now refuse instead.
357    ///
358    /// Skipped rather than failed when `npm` is absent from `PATH`.
359    #[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        // A lockfile that never heard of `left-pad`, so it cannot rebuild the tree.
372        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    /// A missing hidden lockfile means npm never recorded what it installed — that is
440    /// "nothing to compare", not drift.
441    #[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}