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, 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}