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}