Skip to main content

amont_runtime/hooks/
run_tests.rs

1//! pre-push-run-tests-js — run each touched JS package's gate before pushing.
2//!
3//! git feeds pre-push one line per ref on stdin. That list is parsed ONCE by
4//! the dispatcher (see `pushrefs`) and lent here, because stdin can only be
5//! consumed once and more than one check needs it:
6//!     <local ref> <local oid> <remote ref> <remote oid>
7//! An all-zero local oid is a deletion; an all-zero remote oid means the branch
8//! is new, so everything it carries is in range.
9
10use super::common::program;
11use crate::check::Outcome;
12use crate::git;
13use std::process::{Command, Stdio};
14
15/// Whichever of these the package defines, cheapest first, stopping at the
16/// first failure — a type error costs seconds, not a full suite.
17///
18/// `lint` is deliberately absent: pre-commit-lint-js already lints staged files
19/// with the repo's pinned eslint, so repeating it here costs time and catches
20/// nothing new.
21///
22/// That argument is not special to `lint`. Any entry here can be moved earlier
23/// by a repository — see [`gated_at_commit`] — and once it runs on every commit,
24/// running it again on push is the same pure repetition. `typecheck` is the one
25/// people move: push is too late to hear about a type error you introduced an
26/// hour ago.
27const GATE: [&str; 3] = ["typecheck", "test:unit", "test"];
28
29/// GATE entries this repository already runs at COMMIT time, and so must not
30/// run again here.
31///
32/// A repository moves one earlier by declaring it in `amont.conf` under the
33/// name of the script:
34///
35/// ```text
36/// pre-commit  typecheck  .ts .tsx  block  npm run typecheck
37/// ```
38///
39/// The name is the contract, deliberately — matching on the command would have
40/// to guess at `npm` vs `pnpm` vs `yarn`, at `--silent`, at a wrapper script,
41/// and would answer "no" to things that plainly are the same check.
42///
43/// **Only a declaration that would actually run counts**, and both halves of
44/// that matter:
45///
46///   * `Kind::Runnable` excludes an unusable line and — through
47///     [`crate::manifest::gate`] — an UNTRUSTED manifest. A repository could
48///     otherwise declare `pre-commit typecheck`, never be trusted, and silently
49///     have types checked at neither end.
50///   * `hook.skip` is honoured for the same reason, one layer up: a declaration
51///     the author has switched off is not a check.
52///
53/// Get either wrong and the result is the failure this project is arranged
54/// against — a push that reports a green gate having run nothing.
55fn gated_at_commit() -> Vec<&'static str> {
56    let skips = crate::configured_skips();
57    let declared = crate::manifest::externals();
58    GATE.iter()
59        .copied()
60        .filter(|script| {
61            declared.iter().any(|ext| {
62                ext.stage == crate::check::Stage::PreCommit
63                    && matches!(ext.kind, crate::manifest::Kind::Runnable { .. })
64                    && ext.short_name == *script
65                    && !skips.iter().any(|s| crate::skip_suppresses(&ext.id, s))
66            })
67        })
68        .collect()
69}
70
71/// The extensions this check treats as "JS worth testing". Exported so
72/// `registry.rs` declares the scope from the same constant — see
73/// `lint_json_yaml::EXTS` for the drift this prevents.
74pub const JS_EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
75
76fn is_js(file: &str) -> bool {
77    JS_EXTS.iter().any(|e| file.ends_with(e))
78}
79
80fn parent_of(path: &str) -> &str {
81    match path.rfind('/') {
82        Some(i) => &path[..i],
83        None => "",
84    }
85}
86
87/// Does `package.json` define `script` under "scripts"?
88///
89/// A brace-matched scan rather than a JSON parser: the only question is whether
90/// one key exists in one object, and a dependency-free binary is the point of
91/// this migration. Restricted to the "scripts" object so a same-named key
92/// elsewhere (a dependency called `test`, say) cannot answer for it.
93pub fn defines_script(pkg_json: &str, script: &str) -> bool {
94    let Some(k) = pkg_json.find("\"scripts\"") else {
95        return false;
96    };
97    let Some(open_rel) = pkg_json[k..].find('{') else {
98        return false;
99    };
100    let open = k + open_rel;
101    let bytes = pkg_json.as_bytes();
102    let mut depth = 0usize;
103    let mut end = open;
104    let mut in_str = false;
105    let mut escaped = false;
106    for (i, &c) in bytes.iter().enumerate().skip(open) {
107        if in_str {
108            if escaped {
109                escaped = false;
110            } else if c == b'\\' {
111                escaped = true;
112            } else if c == b'"' {
113                in_str = false;
114            }
115            continue;
116        }
117        match c {
118            b'"' => in_str = true,
119            b'{' => depth += 1,
120            b'}' => {
121                depth -= 1;
122                if depth == 0 {
123                    end = i;
124                    break;
125                }
126            }
127            _ => {}
128        }
129    }
130    if end <= open {
131        return false;
132    }
133    let body = &pkg_json[open..=end];
134    let needle = format!("\"{script}\"");
135    let mut from = 0;
136    while let Some(i) = body[from..].find(&needle) {
137        let at = from + i;
138        let after = &body[at + needle.len()..];
139        if after.trim_start().starts_with(':') {
140            return true;
141        }
142        from = at + needle.len();
143    }
144    false
145}
146
147/// Tracked `package.json` paths, as directories relative to the repo root.
148///
149/// `git ls-files` instead of the shell version's `fd package.json`: it drops
150/// the `fd` dependency (undeclared, and one of two binaries the hooks silently
151/// required), and it is the more correct set anyway — only TRACKED packages can
152/// be part of a push, and node_modules is excluded by construction rather than
153/// by fd happening to honour .gitignore.
154pub fn package_dirs(ls_files: &[String]) -> Vec<String> {
155    let mut dirs: Vec<String> = ls_files
156        .iter()
157        .map(String::as_str)
158        .filter(|f| *f == "package.json" || f.ends_with("/package.json"))
159        .map(|f| parent_of(f).to_string())
160        .collect();
161    dirs.sort();
162    dirs.dedup();
163    dirs
164}
165
166/// Packages that actually contain one of the changed files.
167///
168/// `.iter().any()`, not the shell version's original `.filter()` — an empty
169/// array is truthy in JS, so that selected EVERY package regardless of what
170/// changed. Invisible in a single-package repo, quadratic noise in a monorepo.
171pub fn packages_to_test(pkg_dirs: &[String], changed_dirs: &[String]) -> Vec<String> {
172    pkg_dirs
173        .iter()
174        .filter(|pkg| {
175            changed_dirs.iter().any(|dir| {
176                if pkg.is_empty() {
177                    true
178                } else {
179                    dir == *pkg || dir.starts_with(&format!("{pkg}/"))
180                }
181            })
182        })
183        .cloned()
184        .collect()
185}
186
187/// The GATE, minus what a `pre-commit` declaration already covers.
188///
189/// Split from `run_gate` so the rule can be tested without a package on disk
190/// and without spawning npm — `run_gate` reaches for both.
191pub fn gate_for(pkg_json: &str, already: &[&str]) -> Vec<&'static str> {
192    GATE.iter()
193        .copied()
194        .filter(|s| defines_script(pkg_json, s) && !already.contains(s))
195        .collect()
196}
197
198/// True when the gate passed (or there was none to run).
199fn run_gate(root: &str, folder: &str, already: &[&str]) -> bool {
200    let dir = if folder.is_empty() {
201        root.to_string()
202    } else {
203        format!("{root}/{folder}")
204    };
205    let Ok(pkg) = std::fs::read_to_string(format!("{dir}/package.json")) else {
206        return true;
207    };
208    for script in gate_for(&pkg, already) {
209        // Same hazard as cargo test: git exports GIT_DIR to hooks, and a JS
210        // test that shells out to git would then operate on this repo rather
211        // than its own fixture.
212        let mut cmd = Command::new(program("npm"));
213        cmd.args(["run", script])
214            .current_dir(&dir)
215            .stdin(Stdio::null());
216        super::common::strip_git_env(&mut cmd);
217        let status = cmd.status();
218        match status {
219            Ok(status) if status.success() => {}
220            // The gate's own exit code is not propagated: git only
221            // distinguishes zero from non-zero, and npm's codes said nothing
222            // the message above has not already said.
223            Ok(_) | Err(_) => return false,
224        }
225    }
226    true
227}
228
229pub fn run(refs: &[crate::pushrefs::PushRef]) -> Outcome {
230    // An all-zero oid, of whatever length this repo's hash is (sha1 or sha256).
231    let zero = git::stdout(&["hash-object", "--stdin"])
232        .map(|h| "0".repeat(h.len()))
233        .unwrap_or_else(|| "0".repeat(40));
234
235    let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
236        return Outcome::Passed;
237    };
238    let pkg_dirs = git::stdout_paths(&["ls-files"])
239        .map(|f| package_dirs(&f))
240        .unwrap_or_default();
241
242    // Resolved ONCE, ahead of the loop: it is a property of the repository, not
243    // of a ref or a package, and `externals()` caches on first call anyway.
244    let already = gated_at_commit();
245    // Said out loud, every time, and not folded into the pass line. A check
246    // that stops running is exactly what this project refuses to let happen
247    // quietly — the reader has to be able to see that `typecheck` moved rather
248    // than discover later that nothing ran it.
249    if !already.is_empty() {
250        println!(
251            "{} {} gated at commit instead — not repeating {} here",
252            crate::ui::valid_sign(),
253            already.join(", "),
254            if already.len() == 1 { "it" } else { "them" },
255        );
256    }
257
258    for r in refs {
259        let local_oid = r.local_oid.as_str();
260        if local_oid == zero {
261            continue; // deleting a ref pushes no code
262        }
263        // `pushrefs::changed_files_for` exists for exactly this question, and
264        // this check used to recompute it inline with all three bugs that
265        // function's doc comment records fixing:
266        //
267        //   - a brand-new branch (`remote_oid == zero`) diffed only its TIP,
268        //     so on a multi-commit push an earlier commit's `.ts` change was
269        //     invisible and the suite never ran;
270        //   - a two-dot range handed to `diff-tree` is a two-TREE compare, not
271        //     a commit walk, so a file changed and reverted later in the same
272        //     push netted to nothing;
273        //   - merge commits show NOTHING without `-m`, so a file touched only
274        //     to resolve a conflict selected no package.
275        //
276        // Every one of those let a push proceed GREEN with the suite never
277        // having run. `rust_tools::test` was already the model.
278        let changed = crate::pushrefs::changed_files_for(r, &zero);
279        let changed_dirs: Vec<String> = changed
280            .iter()
281            .map(String::as_str)
282            .filter(|f| is_js(f))
283            .map(|f| parent_of(f).to_string())
284            .collect();
285        if changed_dirs.is_empty() {
286            continue;
287        }
288
289        // Same question as cargo-test: the suite should be answering about
290        // the commits being pushed, not about whatever is open in the
291        // editor — and about THIS ref's commits, not some other ref in the
292        // same push. A single worktree shared across every ref would run a
293        // second ref's tests against a first ref's tree.
294        let (run_in, _guard) = crate::pushed_tree::where_to_run(local_oid, &root);
295        let where_ = run_in.to_string_lossy().into_owned();
296        for folder in packages_to_test(&pkg_dirs, &changed_dirs) {
297            if !run_gate(&where_, &folder, &already) {
298                return Outcome::Failed;
299            }
300        }
301    }
302    Outcome::Passed
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// The filter itself, without a package on disk or an npm to spawn.
310    ///
311    /// Order is part of the contract and is asserted: GATE is cheapest-first so
312    /// a type error costs seconds rather than a full suite, and removing an
313    /// entry must not disturb what is left.
314    #[test]
315    fn the_gate_drops_only_what_commit_already_covers() {
316        let pkg = r#"{"scripts":{"typecheck":"tsc","test":"vitest run"}}"#;
317        assert_eq!(gate_for(pkg, &[]), vec!["typecheck", "test"]);
318        assert_eq!(gate_for(pkg, &["typecheck"]), vec!["test"]);
319        assert_eq!(gate_for(pkg, &["test"]), vec!["typecheck"]);
320        assert!(gate_for(pkg, &["typecheck", "test"]).is_empty());
321        // A name the package does not define is not a script to skip, and
322        // naming one must not disturb the rest.
323        assert_eq!(gate_for(pkg, &["test:unit"]), vec!["typecheck", "test"]);
324    }
325
326    #[test]
327    fn finds_scripts_only_inside_the_scripts_object() {
328        let pkg = r#"{"name":"x","scripts":{"test":"vitest","typecheck":"tsc"},"devDependencies":{"lint":"1"}}"#;
329        assert!(defines_script(pkg, "test"));
330        assert!(defines_script(pkg, "typecheck"));
331        assert!(!defines_script(pkg, "test:unit"));
332        // present, but as a DEPENDENCY — must not answer for the scripts object
333        assert!(!defines_script(pkg, "lint"));
334    }
335
336    #[test]
337    fn survives_nested_objects_and_escaped_quotes() {
338        let pkg = r#"{"scripts":{"test":"echo \"hi\"","build":"x"},"other":{"test":"no"}}"#;
339        assert!(defines_script(pkg, "test"));
340        assert!(defines_script(pkg, "build"));
341        assert!(!defines_script(pkg, "other"));
342    }
343
344    #[test]
345    fn no_scripts_object_means_no_scripts() {
346        assert!(!defines_script(r#"{"name":"x"}"#, "test"));
347    }
348
349    #[test]
350    fn collects_package_directories() {
351        let ls: Vec<String> = [
352            "package.json",
353            "apps/web/package.json",
354            "apps/web/src/a.ts",
355            "README.md",
356        ]
357        .into_iter()
358        .map(String::from)
359        .collect();
360        assert_eq!(
361            package_dirs(&ls),
362            vec!["".to_string(), "apps/web".to_string()]
363        );
364    }
365
366    /// The JS bug: `.filter()` returns an array, `[]` is truthy, so every
367    /// package was selected whatever changed.
368    #[test]
369    fn selects_only_packages_containing_a_change() {
370        let pkgs = vec!["apps/web".to_string(), "apps/api".to_string()];
371        let changed = vec!["apps/web/src".to_string()];
372        assert_eq!(
373            packages_to_test(&pkgs, &changed),
374            vec!["apps/web".to_string()]
375        );
376    }
377
378    #[test]
379    fn the_repo_root_package_matches_any_change() {
380        let pkgs = vec!["".to_string()];
381        assert_eq!(
382            packages_to_test(&pkgs, &["src".to_string()]),
383            vec!["".to_string()]
384        );
385    }
386}