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, and actually block, counts**:
44///
45/// * `Kind::Runnable` excludes an unusable line and — through
46/// [`crate::manifest::gate`] — an UNTRUSTED manifest. A repository could
47/// otherwise declare `pre-commit typecheck`, never be trusted, and silently
48/// have types checked at neither end.
49/// * `hook.skip` is honoured for the same reason, one layer up: a declaration
50/// the author has switched off is not a check.
51/// * The EFFECTIVE severity must be `block`, overrides included. A `warn`
52/// declaration lets the commit through on failure, so treating it as
53/// commit-time coverage would replace a blocking push check with a
54/// non-blocking commit one.
55///
56/// The declaration's scope rides along rather than being judged here: whether
57/// it covered a given push depends on which files that push changed, which is
58/// a per-ref question `run` answers with [`crate::check::Scope::covers_all`].
59///
60/// Get any of these wrong and the result is the failure this project is
61/// arranged against — a push that reports a green gate having run nothing.
62/// A declaration passing this filter is still only a PROMISE: whether the
63/// check executed for the commits actually being pushed is what
64/// [`crate::gate_stamp`]'s per-commit stamps answer, in `run`.
65pub(crate) fn gated_at_commit(declared: &[crate::manifest::External]) -> Vec<GateDecl> {
66 let mut decls = blocking_commit_decls(declared);
67 decls.retain(|d| GATE.contains(&d.script.as_str()));
68 decls
69}
70
71/// EVERY blocking commit-time declaration, whatever its name — the general
72/// form [`gated_at_commit`] narrows to the npm GATE vocabulary. This is what
73/// earns stamps and what the bypass ledger counts: a `pre-commit test *.rs
74/// block cargo test` line gates exactly the way `typecheck` always has, and
75/// a same-named pre-push declaration defers to its stamps (see
76/// [`pair_verdict`]). The npm push gate was the first consumer of this
77/// machinery, not its definition.
78pub(crate) fn blocking_commit_decls(declared: &[crate::manifest::External]) -> Vec<GateDecl> {
79 // The fast path: pre-commit calls this on every commit to know what to
80 // stamp, and most repositories declare nothing — no pre-commit
81 // declaration means no git spawns for skips and severity overrides.
82 if gate_names_declared(declared).is_empty() {
83 return Vec::new();
84 }
85 let skips = crate::configured_skips();
86 let severities = crate::registry::Overrides::read();
87 declared
88 .iter()
89 .filter_map(|ext| {
90 let crate::manifest::Kind::Runnable { scope, .. } = &ext.kind else {
91 return None;
92 };
93 (ext.stage == crate::check::Stage::PreCommit
94 && severities.of(ext) == crate::check::Severity::Block
95 && !skips.iter().any(|s| crate::skip_suppresses(&ext.id, s)))
96 .then(|| GateDecl {
97 script: ext.short_name.clone(),
98 id: ext.id.clone(),
99 scope: *scope,
100 })
101 })
102 .collect()
103}
104
105/// Names this manifest declares at pre-commit, before any config is read —
106/// the zero-spawn question "could this repository have a commit-time gate at
107/// all". [`blocking_commit_decls`] and the bypass ledger's fast path both
108/// start here, from the same predicate, so they cannot drift.
109pub(crate) fn gate_names_declared(declared: &[crate::manifest::External]) -> Vec<String> {
110 declared
111 .iter()
112 .filter(|ext| ext.stage == crate::check::Stage::PreCommit)
113 .map(|ext| ext.short_name.clone())
114 .collect()
115}
116
117/// What a declared PRE-PUSH external should do about a same-named
118/// commit-time declaration — the general form of the npm gate's stamp
119/// suppression, for names the GATE list never heard of. Declaring `test`
120/// (or `lint`, or anything) at BOTH stages is the contract: the pre-commit
121/// side earns per-commit stamps, and the pre-push side runs only for pushes
122/// carrying commits with no record of it.
123pub(crate) enum PairVerdict {
124 /// No blocking commit-time pair covers this push — run normally.
125 NotPaired,
126 /// Every pushed commit in the pair's scope carries its stamp — skip,
127 /// saying so.
128 Gated,
129 /// A pair exists but this many pushed commits carry no record of it —
130 /// run, saying why the declaration's word was not enough.
131 Unstamped(usize),
132}
133
134/// Judge a pre-push declared external against its commit-time pair, with
135/// exactly the npm gate's rules: the pair must be BLOCKING (a warn check
136/// vouches for nothing), its scope must have seen every file this push
137/// changes that the push-side check would fire on, and every relevant
138/// commit must carry its stamp. Any git refusal reads as "no stamp", which
139/// runs the check — the only safe direction.
140pub(crate) fn pair_verdict(
141 ext: &crate::manifest::External,
142 manifest: &crate::manifest::Manifest,
143 push: &crate::pushrefs::PushRefs,
144) -> PairVerdict {
145 let crate::manifest::Kind::Runnable {
146 scope: push_scope, ..
147 } = &ext.kind
148 else {
149 return PairVerdict::NotPaired;
150 };
151 // Cheap name test before any config read or git spawn: most pre-push
152 // declarations have no commit-time namesake.
153 if !manifest
154 .externals
155 .iter()
156 .any(|e| e.stage == crate::check::Stage::PreCommit && e.short_name == ext.short_name)
157 {
158 return PairVerdict::NotPaired;
159 }
160 let Some(pair) = blocking_commit_decls(&manifest.externals)
161 .into_iter()
162 .find(|d| d.script == ext.short_name)
163 else {
164 return PairVerdict::NotPaired;
165 };
166 let zero = git::stdout(&["hash-object", "--stdin"])
167 .map(|h| "0".repeat(h.len()))
168 .unwrap_or_else(|| "0".repeat(40));
169 let mut unstamped = 0usize;
170 let mut judged_any = false;
171 for r in push.get() {
172 let changed = crate::pushrefs::changed_files_for(r, &zero);
173 let relevant: Vec<String> = changed
174 .iter()
175 .filter(|f| push_scope.matches(std::slice::from_ref(f)))
176 .cloned()
177 .collect();
178 if relevant.is_empty() {
179 continue; // this ref never fires the push-side check
180 }
181 judged_any = true;
182 if !pair.scope.covers_all(&relevant) {
183 // The pair has not judged everything this push changes — its
184 // word covers nothing here, silently: claiming partial coverage
185 // out loud would read as reassurance.
186 return PairVerdict::NotPaired;
187 }
188 let per_commit = crate::pushrefs::commits_and_files_for(r, &zero);
189 let ids: Vec<String> = per_commit.iter().map(|(c, _)| c.clone()).collect();
190 let stamps = crate::gate_stamp::stamps_for(&ids);
191 unstamped += per_commit
192 .iter()
193 .filter(|(_, files)| pair.scope.matches(files))
194 .filter(|(commit, _)| !stamps.get(commit).is_some_and(|s| s.contains(&pair.script)))
195 .count();
196 }
197 if !judged_any {
198 return PairVerdict::NotPaired;
199 }
200 if unstamped == 0 {
201 PairVerdict::Gated
202 } else {
203 PairVerdict::Unstamped(unstamped)
204 }
205}
206
207/// One gate entry a commit-time declaration stands in for.
208pub(crate) struct GateDecl {
209 /// The declared name — a GATE script for the npm push gate, any name
210 /// for a declared pre-commit/pre-push pair.
211 pub script: String,
212 /// `<stage>-<name>` — what dispatch outcomes and `hook.skip` key on.
213 /// Carried so pre-commit can match "this declaration" to "that outcome"
214 /// without re-deriving the id and drifting.
215 pub id: String,
216 /// The declaration's scope, judged per ref by
217 /// [`crate::check::Scope::covers_all`].
218 pub scope: crate::check::Scope,
219}
220
221/// The extensions this check treats as "JS worth testing". Exported so
222/// `registry.rs` declares the scope from the same constant — see
223/// `lint_json_yaml::EXTS` for the drift this prevents.
224pub const JS_EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
225
226fn is_js(file: &str) -> bool {
227 JS_EXTS.iter().any(|e| file.ends_with(e))
228}
229
230fn parent_of(path: &str) -> &str {
231 match path.rfind('/') {
232 Some(i) => &path[..i],
233 None => "",
234 }
235}
236
237/// Does `package.json` define `script` under "scripts"?
238///
239/// A brace-matched scan rather than a JSON parser: the only question is whether
240/// one key exists in one object, and a dependency-free binary is the point of
241/// this migration. Restricted to the TOP-LEVEL "scripts" object so a
242/// same-named key elsewhere (a dependency called `test`, say) cannot answer
243/// for it.
244pub fn defines_script(pkg_json: &str, script: &str) -> bool {
245 let Some(body) = scripts_object(pkg_json) else {
246 return false;
247 };
248 let needle = format!("\"{script}\"");
249 let mut from = 0;
250 while let Some(i) = body[from..].find(&needle) {
251 let at = from + i;
252 let after = &body[at + needle.len()..];
253 if after.trim_start().starts_with(':') {
254 return true;
255 }
256 from = at + needle.len();
257 }
258 false
259}
260
261/// The text of the top-level `"scripts"` object, braces included.
262///
263/// A depth-tracking scan, not `find("\"scripts\"")`: anchoring on the FIRST
264/// occurrence of the substring anywhere meant `{"files":["scripts"],…}`
265/// handed the brace-matcher whichever object came next — so a dependency
266/// named `test` answered as a script while the real scripts object was never
267/// read, which is exactly the false positive `defines_script`'s doc promises
268/// away. A string only counts as a key when it sits at depth 1 (inside the
269/// document object, outside any array) AND its next non-space byte is `:` —
270/// `{"name": "scripts"}` is a value, not the key.
271fn scripts_object(pkg_json: &str) -> Option<&str> {
272 let bytes = pkg_json.as_bytes();
273 let mut depth = 0usize;
274 let mut i = 0usize;
275 while i < bytes.len() {
276 match bytes[i] {
277 b'"' => {
278 let start = i + 1;
279 let close = string_end(bytes, start)?;
280 let content = &pkg_json[start..close];
281 i = close + 1;
282 if depth != 1 {
283 continue;
284 }
285 let mut j = i;
286 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
287 j += 1;
288 }
289 if j >= bytes.len() || bytes[j] != b':' {
290 continue; // a value, not a key
291 }
292 if content != "scripts" {
293 continue;
294 }
295 let mut k = j + 1;
296 while k < bytes.len() && bytes[k].is_ascii_whitespace() {
297 k += 1;
298 }
299 // The one top-level "scripts" key exists but is not an
300 // object: there are no scripts, and no later impostor may
301 // answer instead.
302 if k >= bytes.len() || bytes[k] != b'{' {
303 return None;
304 }
305 let end = object_end(bytes, k)?;
306 return Some(&pkg_json[k..=end]);
307 }
308 b'{' | b'[' => {
309 depth += 1;
310 i += 1;
311 }
312 b'}' | b']' => {
313 depth = depth.saturating_sub(1);
314 i += 1;
315 }
316 _ => i += 1,
317 }
318 }
319 None
320}
321
322/// Index of the closing quote of the string whose content starts at `from`.
323fn string_end(bytes: &[u8], from: usize) -> Option<usize> {
324 let mut escaped = false;
325 for (i, &c) in bytes.iter().enumerate().skip(from) {
326 if escaped {
327 escaped = false;
328 } else if c == b'\\' {
329 escaped = true;
330 } else if c == b'"' {
331 return Some(i);
332 }
333 }
334 None
335}
336
337/// Index of the `}` matching the `{` at `open`, string-aware.
338fn object_end(bytes: &[u8], open: usize) -> Option<usize> {
339 let mut depth = 0usize;
340 let mut i = open;
341 while i < bytes.len() {
342 match bytes[i] {
343 b'"' => i = string_end(bytes, i + 1)?,
344 b'{' => depth += 1,
345 b'}' => {
346 depth -= 1;
347 if depth == 0 {
348 return Some(i);
349 }
350 }
351 _ => {}
352 }
353 i += 1;
354 }
355 None
356}
357
358/// Tracked `package.json` paths, as directories relative to the repo root.
359///
360/// `git ls-files` instead of the shell version's `fd package.json`: it drops
361/// the `fd` dependency (undeclared, and one of two binaries the hooks silently
362/// required), and it is the more correct set anyway — only TRACKED packages can
363/// be part of a push, and node_modules is excluded by construction rather than
364/// by fd happening to honour .gitignore.
365pub fn package_dirs(ls_files: &[String]) -> Vec<String> {
366 let mut dirs: Vec<String> = ls_files
367 .iter()
368 .map(String::as_str)
369 .filter(|f| *f == "package.json" || f.ends_with("/package.json"))
370 .map(|f| parent_of(f).to_string())
371 .collect();
372 dirs.sort();
373 dirs.dedup();
374 dirs
375}
376
377/// Packages that actually contain one of the changed files.
378///
379/// `.iter().any()`, not the shell version's original `.filter()` — an empty
380/// array is truthy in JS, so that selected EVERY package regardless of what
381/// changed. Invisible in a single-package repo, quadratic noise in a monorepo.
382pub fn packages_to_test(pkg_dirs: &[String], changed_dirs: &[String]) -> Vec<String> {
383 pkg_dirs
384 .iter()
385 .filter(|pkg| {
386 changed_dirs.iter().any(|dir| {
387 if pkg.is_empty() {
388 true
389 } else {
390 dir == *pkg || dir.starts_with(&format!("{pkg}/"))
391 }
392 })
393 })
394 .cloned()
395 .collect()
396}
397
398/// The GATE, minus what a `pre-commit` declaration already covers.
399///
400/// Split from `run_gate` so the rule can be tested without a package on disk
401/// and without spawning npm — `run_gate` reaches for both.
402pub fn gate_for(pkg_json: &str, already: &[&str]) -> Vec<&'static str> {
403 GATE.iter()
404 .copied()
405 .filter(|s| defines_script(pkg_json, s) && !already.contains(s))
406 .collect()
407}
408
409/// True when the gate passed (or there was none to run).
410fn run_gate(root: &str, folder: &str, already: &[&str]) -> bool {
411 let dir = if folder.is_empty() {
412 root.to_string()
413 } else {
414 format!("{root}/{folder}")
415 };
416 let Ok(pkg) = std::fs::read_to_string(format!("{dir}/package.json")) else {
417 return true;
418 };
419 for script in gate_for(&pkg, already) {
420 // Same hazard as cargo test: git exports GIT_DIR to hooks, and a JS
421 // test that shells out to git would then operate on this repo rather
422 // than its own fixture.
423 let mut cmd = Command::new(program("npm"));
424 cmd.args(["run", script])
425 .current_dir(&dir)
426 .stdin(Stdio::null());
427 super::common::strip_git_env(&mut cmd);
428 match super::common::status_streamed(&mut cmd) {
429 Ok(super::common::Ran::Status(status)) if status.success() => {}
430 Ok(super::common::Ran::TimedOut(budget)) => {
431 super::common::say_timed_out(script, budget);
432 return false;
433 }
434 // The gate's own exit code is not propagated: git only
435 // distinguishes zero from non-zero, and npm's codes said nothing
436 // the message above has not already said.
437 Ok(_) | Err(_) => return false,
438 }
439 }
440 true
441}
442
443pub fn run(refs: &[crate::pushrefs::PushRef], declared: &[crate::manifest::External]) -> Outcome {
444 // An all-zero oid, of whatever length this repo's hash is (sha1 or sha256).
445 let zero = git::stdout(&["hash-object", "--stdin"])
446 .map(|h| "0".repeat(h.len()))
447 .unwrap_or_else(|| "0".repeat(40));
448
449 // `Unavailable`, never `Passed`, when git itself will not answer: a push
450 // gate that reports green having asked nothing is the exact conflation
451 // `Outcome::Unavailable` exists to prevent (see its doc in check.rs), and
452 // the same split `install::repo_hooks` was rewritten for. The dispatcher
453 // says "could not run" and does not block — loud, and honest.
454 let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
455 super::common::warn("run-tests-js: git would not answer — the gate did NOT run");
456 return Outcome::Unavailable;
457 };
458 let Some(tracked) = git::stdout_paths(&["ls-files"]) else {
459 super::common::warn("run-tests-js: git would not answer — the gate did NOT run");
460 return Outcome::Unavailable;
461 };
462 let pkg_dirs = package_dirs(&tracked);
463
464 // The DECLARATIONS are a property of the repository, resolved once; whether
465 // one covers a given push is a property of that ref's changed files and of
466 // its commits' stamps, judged inside the loop.
467 let declared_gate = gated_at_commit(declared);
468 let mut announced: Vec<String> = Vec::new();
469 let mut warned: Vec<String> = Vec::new();
470
471 for r in refs {
472 let local_oid = r.local_oid.as_str();
473 if local_oid == zero {
474 continue; // deleting a ref pushes no code
475 }
476 // `pushrefs::changed_files_for` exists for exactly this question, and
477 // this check used to recompute it inline with all three bugs that
478 // function's doc comment records fixing:
479 //
480 // - a brand-new branch (`remote_oid == zero`) diffed only its TIP,
481 // so on a multi-commit push an earlier commit's `.ts` change was
482 // invisible and the suite never ran;
483 // - a two-dot range handed to `diff-tree` is a two-TREE compare, not
484 // a commit walk, so a file changed and reverted later in the same
485 // push netted to nothing;
486 // - merge commits show NOTHING without `-m`, so a file touched only
487 // to resolve a conflict selected no package.
488 //
489 // Every one of those let a push proceed GREEN with the suite never
490 // having run. `rust_tools::test` was already the model.
491 let changed = crate::pushrefs::changed_files_for(r, &zero);
492 let js_changed: Vec<String> = changed.iter().filter(|f| is_js(f)).cloned().collect();
493 let changed_dirs: Vec<String> = js_changed
494 .iter()
495 .map(|f| parent_of(f).to_string())
496 .collect();
497 if changed_dirs.is_empty() {
498 continue;
499 }
500
501 // A declaration only stands in for this push if its scope saw every JS
502 // file the push changes — and only for the ROOT package, because that
503 // is where the declared command runs. A sub-package's gate is a
504 // different command in a different directory, and no root declaration
505 // has judged it.
506 //
507 // And the declaration must have EXECUTED, which is the stamps'
508 // question: every commit in this push the declaration would have
509 // fired on must carry a `gate_stamp` note naming the script. A commit
510 // made with `--no-verify`, from a client that runs no hooks, on a
511 // machine without amont, or with a rewritten hash has no stamp — and
512 // an unstamped commit is one the check never judged, so the gate runs
513 // rather than trusting the declaration's word for it.
514 let candidates: Vec<&GateDecl> = declared_gate
515 .iter()
516 .filter(|d| d.scope.covers_all(&js_changed))
517 .collect();
518 let mut already: Vec<String> = Vec::new();
519 if !candidates.is_empty() {
520 let per_commit = crate::pushrefs::commits_and_files_for(r, &zero);
521 let ids: Vec<String> = per_commit.iter().map(|(c, _)| c.clone()).collect();
522 let stamps = crate::gate_stamp::stamps_for(&ids);
523 for d in candidates {
524 let unstamped = per_commit
525 .iter()
526 .filter(|(_, files)| d.scope.matches(files))
527 .filter(|(commit, _)| {
528 !stamps.get(commit).is_some_and(|s| s.contains(&d.script))
529 })
530 .count();
531 if unstamped == 0 {
532 already.push(d.script.clone());
533 } else if !warned.contains(&d.script) {
534 crate::say!(
535 "{} {} is declared at commit time, but {unstamped} pushed \
536 commit{} carr{} no record of it — running it here",
537 crate::ui::warning_sign(),
538 d.script,
539 if unstamped == 1 { "" } else { "s" },
540 if unstamped == 1 { "ies" } else { "y" },
541 );
542 warned.push(d.script.clone());
543 }
544 }
545 }
546
547 // Same question as cargo-test: the suite should be answering about
548 // the commits being pushed, not about whatever is open in the
549 // editor — and about THIS ref's commits, not some other ref in the
550 // same push. A single worktree shared across every ref would run a
551 // second ref's tests against a first ref's tree.
552 let (run_in, _guard) = crate::pushed_tree::where_to_run(local_oid, &root);
553 let where_ = run_in.to_string_lossy().into_owned();
554 let folders = packages_to_test(&pkg_dirs, &changed_dirs);
555
556 // Said out loud, and not folded into the pass line. A check that stops
557 // running is exactly what this project refuses to let happen quietly —
558 // the reader has to be able to see that `typecheck` moved rather than
559 // discover later that nothing ran it. Announced only when the skip is
560 // actually applied to this ref: claiming suppression that severity,
561 // scope or a missing root package disqualified would be the same lie
562 // in the other direction.
563 if folders.iter().any(|f| f.is_empty()) {
564 let newly: Vec<String> = already
565 .iter()
566 .filter(|s| !announced.contains(s))
567 .cloned()
568 .collect();
569 if !newly.is_empty() {
570 crate::say!(
571 "{} {} gated at commit instead — not repeating {} here",
572 crate::ui::valid_sign(),
573 newly.join(", "),
574 if newly.len() == 1 { "it" } else { "them" },
575 );
576 announced.extend(newly);
577 }
578 }
579
580 let already_strs: Vec<&str> = already.iter().map(String::as_str).collect();
581 for folder in folders {
582 // Root only: the declared command runs at the repo root.
583 let already_here: &[&str] = if folder.is_empty() {
584 &already_strs
585 } else {
586 &[]
587 };
588 if !run_gate(&where_, &folder, already_here) {
589 return Outcome::Failed;
590 }
591 }
592 }
593 Outcome::Passed
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 /// The filter itself, without a package on disk or an npm to spawn.
601 ///
602 /// Order is part of the contract and is asserted: GATE is cheapest-first so
603 /// a type error costs seconds rather than a full suite, and removing an
604 /// entry must not disturb what is left.
605 #[test]
606 fn the_gate_drops_only_what_commit_already_covers() {
607 let pkg = r#"{"scripts":{"typecheck":"tsc","test":"vitest run"}}"#;
608 assert_eq!(gate_for(pkg, &[]), vec!["typecheck", "test"]);
609 assert_eq!(gate_for(pkg, &["typecheck"]), vec!["test"]);
610 assert_eq!(gate_for(pkg, &["test"]), vec!["typecheck"]);
611 assert!(gate_for(pkg, &["typecheck", "test"]).is_empty());
612 // A name the package does not define is not a script to skip, and
613 // naming one must not disturb the rest.
614 assert_eq!(gate_for(pkg, &["test:unit"]), vec!["typecheck", "test"]);
615 }
616
617 /// Coverage is all-match: one changed file outside the declaration's scope
618 /// means commits this push carries were never judged by it.
619 #[test]
620 fn a_scope_covers_only_when_every_changed_file_matches() {
621 use crate::check::Scope;
622 const TS_ONLY: Scope = Scope::files(&[".ts", ".tsx"]);
623 let all_ts: Vec<String> = vec!["src/a.ts".into(), "src/b.tsx".into()];
624 let mixed: Vec<String> = vec!["src/a.ts".into(), "src/legacy.js".into()];
625 let js_only: Vec<String> = vec!["src/legacy.js".into()];
626 assert!(TS_ONLY.covers_all(&all_ts));
627 assert!(!TS_ONLY.covers_all(&mixed));
628 assert!(!TS_ONLY.covers_all(&js_only));
629 // An empty scope is `*` — it saw everything.
630 assert!(Scope::ALWAYS.covers_all(&mixed));
631 // Nothing relevant changed: nothing was missed.
632 assert!(TS_ONLY.covers_all(&[]));
633
634 // A bare-filename scope covers by BASENAME, never by suffix.
635 const BY_NAME: Scope = Scope {
636 files: &[],
637 names: &["package.json"],
638 opt_in: &[],
639 not_during: &[],
640 };
641 assert!(BY_NAME.covers_all(&["apps/web/package.json".into()]));
642 assert!(!BY_NAME.covers_all(&["not-package.json".into()]));
643 }
644
645 #[test]
646 fn finds_scripts_only_inside_the_scripts_object() {
647 let pkg = r#"{"name":"x","scripts":{"test":"vitest","typecheck":"tsc"},"devDependencies":{"lint":"1"}}"#;
648 assert!(defines_script(pkg, "test"));
649 assert!(defines_script(pkg, "typecheck"));
650 assert!(!defines_script(pkg, "test:unit"));
651 // present, but as a DEPENDENCY — must not answer for the scripts object
652 assert!(!defines_script(pkg, "lint"));
653 }
654
655 #[test]
656 fn survives_nested_objects_and_escaped_quotes() {
657 let pkg = r#"{"scripts":{"test":"echo \"hi\"","build":"x"},"other":{"test":"no"}}"#;
658 assert!(defines_script(pkg, "test"));
659 assert!(defines_script(pkg, "build"));
660 assert!(!defines_script(pkg, "other"));
661 }
662
663 #[test]
664 fn no_scripts_object_means_no_scripts() {
665 assert!(!defines_script(r#"{"name":"x"}"#, "test"));
666 }
667
668 /// The misanchor this scan replaced: `find("\"scripts\"")` hit the string
669 /// inside the `files` ARRAY, brace-matched the dependencies object that
670 /// followed, and a dependency named `test` answered as a script — while
671 /// the real scripts object was never read.
672 #[test]
673 fn a_scripts_string_elsewhere_cannot_anchor_the_scan() {
674 let pkg = r#"{"files":["scripts"],"dependencies":{"test":"1.0"},"scripts":{"build":"x"}}"#;
675 assert!(
676 !defines_script(pkg, "test"),
677 "a dependency answered as a script"
678 );
679 assert!(
680 defines_script(pkg, "build"),
681 "the real scripts object was skipped"
682 );
683
684 // The same shape via a string VALUE rather than an array element.
685 let pkg =
686 r#"{"description":"scripts","dependencies":{"test":"1.0"},"scripts":{"build":"x"}}"#;
687 assert!(!defines_script(pkg, "test"));
688 assert!(defines_script(pkg, "build"));
689
690 // A NESTED "scripts" key (inside another object) is not the top-level
691 // one.
692 let pkg = r#"{"config":{"scripts":{"test":"inner"}},"scripts":{"build":"x"}}"#;
693 assert!(!defines_script(pkg, "test"));
694 assert!(defines_script(pkg, "build"));
695 }
696
697 /// `"scripts"` as a top-level key whose value is not an object defines
698 /// nothing — and no later impostor may answer instead.
699 #[test]
700 fn a_non_object_scripts_value_defines_nothing() {
701 assert!(!defines_script(r#"{"scripts":"echo hi"}"#, "test"));
702 assert!(!defines_script(
703 r#"{"scripts":"x","other":{"test":"y"}}"#,
704 "test"
705 ));
706 }
707
708 #[test]
709 fn collects_package_directories() {
710 let ls: Vec<String> = [
711 "package.json",
712 "apps/web/package.json",
713 "apps/web/src/a.ts",
714 "README.md",
715 ]
716 .into_iter()
717 .map(String::from)
718 .collect();
719 assert_eq!(
720 package_dirs(&ls),
721 vec!["".to_string(), "apps/web".to_string()]
722 );
723 }
724
725 /// The JS bug: `.filter()` returns an array, `[]` is truthy, so every
726 /// package was selected whatever changed.
727 #[test]
728 fn selects_only_packages_containing_a_change() {
729 let pkgs = vec!["apps/web".to_string(), "apps/api".to_string()];
730 let changed = vec!["apps/web/src".to_string()];
731 assert_eq!(
732 packages_to_test(&pkgs, &changed),
733 vec!["apps/web".to_string()]
734 );
735 }
736
737 #[test]
738 fn the_repo_root_package_matches_any_change() {
739 let pkgs = vec!["".to_string()];
740 assert_eq!(
741 packages_to_test(&pkgs, &["src".to_string()]),
742 vec!["".to_string()]
743 );
744 }
745}