Skip to main content

amont_runtime/hooks/
package_lock.rs

1//! pre-commit-package-lock — keep package.json and its lockfile in step.
2//!
3//! Scoped per directory: a package.json that is not a real npm project (no
4//! lockfile beside it — e.g. the `.git/hooks/package.json` type-marker) never
5//! demands one, and in a monorepo one project's lockfile does not satisfy
6//! another's.
7//!
8//! ## Deliberately non-interactive
9//!
10//! This check runs on one of up to twenty WORKER THREADS in pre-commit's
11//! concurrent fan-out. It used to call `trust::confirm`, which blocks on
12//! `read_line` from `/dev/tty` — so the whole commit stopped dead while the
13//! other nineteen checks went on printing over the prompt, `thread::scope`
14//! refused to return until somebody answered, and the question itself was
15//! usually scrolled off the screen. The commit simply looked hung.
16//!
17//! A pre-scan phase would mean teaching the generic `&[&dyn Check]` fan-out
18//! about "checks that may ask a question", for exactly one check — and
19//! `confirm()` already returns false without a tty, so the interactive path was
20//! the exception rather than the rule. So there is no question: a forgotten
21//! lockfile FAILS, and the message names the two documented ways past it.
22//!
23//! `trust::confirm` itself stays; `install.rs` calls it from the
24//! single-threaded install path, where a prompt is the whole point.
25
26use super::common::{fail, hl, ok, repo_root, staged_files, warn};
27use crate::check::Outcome;
28use std::path::Path;
29
30fn dir_of(path: &str) -> &str {
31    match path.rfind('/') {
32        Some(i) => &path[..i],
33        None => ".",
34    }
35}
36
37fn sibling(dir: &str, name: &str) -> String {
38    if dir == "." {
39        name.to_string()
40    } else {
41        format!("{dir}/{name}")
42    }
43}
44
45pub struct Verdict {
46    /// package.json staged, a real lockfile exists on disk but is not staged.
47    pub forgot_lock: Vec<String>,
48    /// lockfile staged without its package.json.
49    pub orphan_lock: Vec<String>,
50}
51
52pub fn classify(staged: &[String], lock_exists: impl Fn(&str) -> bool) -> Verdict {
53    let is_staged = |p: &str| staged.iter().any(|s| s == p);
54    let mut v = Verdict {
55        forgot_lock: Vec::new(),
56        orphan_lock: Vec::new(),
57    };
58    for f in staged {
59        let dir = dir_of(f);
60        let base = f.rsplit('/').next().unwrap_or(f);
61        match base {
62            "package.json" => {
63                let lock = sibling(dir, "package-lock.json");
64                if is_staged(&lock) {
65                    continue; // both staged → in sync
66                }
67                if lock_exists(&lock) {
68                    v.forgot_lock.push(f.clone());
69                }
70            }
71            "package-lock.json" => {
72                let pkg = sibling(dir, "package.json");
73                if !is_staged(&pkg) {
74                    v.orphan_lock.push(f.clone());
75                }
76            }
77            _ => {}
78        }
79    }
80    v
81}
82
83/// Report, and never ask — see the module doc for why.
84pub fn run(_args: &[std::ffi::OsString]) -> Outcome {
85    let staged = staged_files(&[]);
86    let root = repo_root();
87    let v = classify(&staged, |lock| Path::new(&root).join(lock).is_file());
88
89    if v.forgot_lock.is_empty() && v.orphan_lock.is_empty() {
90        ok("package.json & package-lock.json look in sync");
91        return Outcome::Passed;
92    }
93    for f in &v.orphan_lock {
94        fail(&format!("{} staged without its package.json", hl(f)));
95    }
96    for f in &v.forgot_lock {
97        warn(&format!(
98            "{} changed but its package-lock.json is not staged",
99            hl(f)
100        ));
101    }
102
103    // Two documented ways past this, and the first is the one-time replacement
104    // for answering "y" on every commit: a severity downgrade keeps the signal
105    // and removes only the block, per-repository and visible in the dashboard.
106    fail(&format!(
107        "Run {} and stage the lockfile. To stop this blocking, {}; \
108         to bypass once, {}",
109        hl("npm install"),
110        hl("git config amont.severity.package-lock warn"),
111        hl("git -c hook.skip=package-lock commit")
112    ));
113    Outcome::Failed
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn v(s: &[&str], locks: &[&str]) -> Verdict {
121        let staged: Vec<String> = s.iter().map(|x| x.to_string()).collect();
122        let locks: Vec<String> = locks.iter().map(|x| x.to_string()).collect();
123        classify(&staged, move |l| locks.iter().any(|x| x == l))
124    }
125
126    #[test]
127    fn both_staged_is_in_sync() {
128        let r = v(
129            &["package.json", "package-lock.json"],
130            &["package-lock.json"],
131        );
132        assert!(r.forgot_lock.is_empty() && r.orphan_lock.is_empty());
133    }
134
135    #[test]
136    fn package_json_alone_with_a_real_lock_on_disk_is_a_forgotten_lock() {
137        let r = v(&["package.json"], &["package-lock.json"]);
138        assert_eq!(r.forgot_lock, vec!["package.json".to_string()]);
139    }
140
141    /// The `.git/hooks/package.json` type-marker case: no lockfile beside it,
142    /// so it is not an npm project and must not demand one.
143    #[test]
144    fn package_json_without_a_lock_on_disk_demands_nothing() {
145        let r = v(&["package.json"], &[]);
146        assert!(r.forgot_lock.is_empty() && r.orphan_lock.is_empty());
147    }
148
149    #[test]
150    fn a_lock_without_its_package_json_is_an_orphan() {
151        let r = v(&["package-lock.json"], &["package-lock.json"]);
152        assert_eq!(r.orphan_lock, vec!["package-lock.json".to_string()]);
153    }
154
155    /// In a monorepo, one project's lockfile does not satisfy another's.
156    #[test]
157    fn scoping_is_per_directory() {
158        let r = v(
159            &["apps/a/package.json", "apps/b/package-lock.json"],
160            &["apps/a/package-lock.json", "apps/b/package-lock.json"],
161        );
162        assert_eq!(r.forgot_lock, vec!["apps/a/package.json".to_string()]);
163        assert_eq!(r.orphan_lock, vec!["apps/b/package-lock.json".to_string()]);
164    }
165}