use super::common::program;
use crate::check::Outcome;
use crate::git;
use std::process::{Command, Stdio};
const GATE: [&str; 3] = ["typecheck", "test:unit", "test"];
pub(crate) fn gated_at_commit(declared: &[crate::manifest::External]) -> Vec<GateDecl> {
if gate_names_declared(declared).is_empty() {
return Vec::new();
}
let skips = crate::configured_skips();
let severities = crate::registry::Overrides::read();
GATE.iter()
.filter_map(|script| {
declared.iter().find_map(|ext| {
let crate::manifest::Kind::Runnable { scope, .. } = &ext.kind else {
return None;
};
(ext.stage == crate::check::Stage::PreCommit
&& ext.short_name == *script
&& severities.of(ext) == crate::check::Severity::Block
&& !skips.iter().any(|s| crate::skip_suppresses(&ext.id, s)))
.then(|| GateDecl {
script,
id: ext.id.clone(),
scope: *scope,
})
})
})
.collect()
}
pub(crate) fn gate_names_declared(declared: &[crate::manifest::External]) -> Vec<&'static str> {
GATE.iter()
.filter(|script| {
declared.iter().any(|ext| {
ext.stage == crate::check::Stage::PreCommit && ext.short_name == **script
})
})
.copied()
.collect()
}
pub(crate) struct GateDecl {
pub script: &'static str,
pub id: String,
pub scope: crate::check::Scope,
}
pub const JS_EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
fn is_js(file: &str) -> bool {
JS_EXTS.iter().any(|e| file.ends_with(e))
}
fn parent_of(path: &str) -> &str {
match path.rfind('/') {
Some(i) => &path[..i],
None => "",
}
}
pub fn defines_script(pkg_json: &str, script: &str) -> bool {
let Some(body) = scripts_object(pkg_json) else {
return false;
};
let needle = format!("\"{script}\"");
let mut from = 0;
while let Some(i) = body[from..].find(&needle) {
let at = from + i;
let after = &body[at + needle.len()..];
if after.trim_start().starts_with(':') {
return true;
}
from = at + needle.len();
}
false
}
fn scripts_object(pkg_json: &str) -> Option<&str> {
let bytes = pkg_json.as_bytes();
let mut depth = 0usize;
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'"' => {
let start = i + 1;
let close = string_end(bytes, start)?;
let content = &pkg_json[start..close];
i = close + 1;
if depth != 1 {
continue;
}
let mut j = i;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j >= bytes.len() || bytes[j] != b':' {
continue; }
if content != "scripts" {
continue;
}
let mut k = j + 1;
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
k += 1;
}
if k >= bytes.len() || bytes[k] != b'{' {
return None;
}
let end = object_end(bytes, k)?;
return Some(&pkg_json[k..=end]);
}
b'{' | b'[' => {
depth += 1;
i += 1;
}
b'}' | b']' => {
depth = depth.saturating_sub(1);
i += 1;
}
_ => i += 1,
}
}
None
}
fn string_end(bytes: &[u8], from: usize) -> Option<usize> {
let mut escaped = false;
for (i, &c) in bytes.iter().enumerate().skip(from) {
if escaped {
escaped = false;
} else if c == b'\\' {
escaped = true;
} else if c == b'"' {
return Some(i);
}
}
None
}
fn object_end(bytes: &[u8], open: usize) -> Option<usize> {
let mut depth = 0usize;
let mut i = open;
while i < bytes.len() {
match bytes[i] {
b'"' => i = string_end(bytes, i + 1)?,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
i += 1;
}
None
}
pub fn package_dirs(ls_files: &[String]) -> Vec<String> {
let mut dirs: Vec<String> = ls_files
.iter()
.map(String::as_str)
.filter(|f| *f == "package.json" || f.ends_with("/package.json"))
.map(|f| parent_of(f).to_string())
.collect();
dirs.sort();
dirs.dedup();
dirs
}
pub fn packages_to_test(pkg_dirs: &[String], changed_dirs: &[String]) -> Vec<String> {
pkg_dirs
.iter()
.filter(|pkg| {
changed_dirs.iter().any(|dir| {
if pkg.is_empty() {
true
} else {
dir == *pkg || dir.starts_with(&format!("{pkg}/"))
}
})
})
.cloned()
.collect()
}
pub fn gate_for(pkg_json: &str, already: &[&str]) -> Vec<&'static str> {
GATE.iter()
.copied()
.filter(|s| defines_script(pkg_json, s) && !already.contains(s))
.collect()
}
fn run_gate(root: &str, folder: &str, already: &[&str]) -> bool {
let dir = if folder.is_empty() {
root.to_string()
} else {
format!("{root}/{folder}")
};
let Ok(pkg) = std::fs::read_to_string(format!("{dir}/package.json")) else {
return true;
};
for script in gate_for(&pkg, already) {
let mut cmd = Command::new(program("npm"));
cmd.args(["run", script])
.current_dir(&dir)
.stdin(Stdio::null());
super::common::strip_git_env(&mut cmd);
match super::common::status_streamed(&mut cmd) {
Ok(super::common::Ran::Status(status)) if status.success() => {}
Ok(super::common::Ran::TimedOut(budget)) => {
super::common::say_timed_out(script, budget);
return false;
}
Ok(_) | Err(_) => return false,
}
}
true
}
pub fn run(refs: &[crate::pushrefs::PushRef], declared: &[crate::manifest::External]) -> Outcome {
let zero = git::stdout(&["hash-object", "--stdin"])
.map(|h| "0".repeat(h.len()))
.unwrap_or_else(|| "0".repeat(40));
let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
super::common::warn("run-tests-js: git would not answer — the gate did NOT run");
return Outcome::Unavailable;
};
let Some(tracked) = git::stdout_paths(&["ls-files"]) else {
super::common::warn("run-tests-js: git would not answer — the gate did NOT run");
return Outcome::Unavailable;
};
let pkg_dirs = package_dirs(&tracked);
let declared_gate = gated_at_commit(declared);
let mut announced: Vec<&'static str> = Vec::new();
let mut warned: Vec<&'static str> = Vec::new();
for r in refs {
let local_oid = r.local_oid.as_str();
if local_oid == zero {
continue; }
let changed = crate::pushrefs::changed_files_for(r, &zero);
let js_changed: Vec<String> = changed.iter().filter(|f| is_js(f)).cloned().collect();
let changed_dirs: Vec<String> = js_changed
.iter()
.map(|f| parent_of(f).to_string())
.collect();
if changed_dirs.is_empty() {
continue;
}
let candidates: Vec<&GateDecl> = declared_gate
.iter()
.filter(|d| d.scope.covers_all(&js_changed))
.collect();
let mut already: Vec<&'static str> = Vec::new();
if !candidates.is_empty() {
let per_commit = crate::pushrefs::commits_and_files_for(r, &zero);
let ids: Vec<String> = per_commit.iter().map(|(c, _)| c.clone()).collect();
let stamps = crate::gate_stamp::stamps_for(&ids);
for d in candidates {
let unstamped = per_commit
.iter()
.filter(|(_, files)| d.scope.matches(files))
.filter(|(commit, _)| {
!stamps
.get(commit)
.is_some_and(|s| s.iter().any(|n| n == d.script))
})
.count();
if unstamped == 0 {
already.push(d.script);
} else if !warned.contains(&d.script) {
crate::say!(
"{} {} is declared at commit time, but {unstamped} pushed \
commit{} carr{} no record of it — running it here",
crate::ui::warning_sign(),
d.script,
if unstamped == 1 { "" } else { "s" },
if unstamped == 1 { "ies" } else { "y" },
);
warned.push(d.script);
}
}
}
let (run_in, _guard) = crate::pushed_tree::where_to_run(local_oid, &root);
let where_ = run_in.to_string_lossy().into_owned();
let folders = packages_to_test(&pkg_dirs, &changed_dirs);
if folders.iter().any(|f| f.is_empty()) {
let newly: Vec<&'static str> = already
.iter()
.copied()
.filter(|s| !announced.contains(s))
.collect();
if !newly.is_empty() {
crate::say!(
"{} {} gated at commit instead — not repeating {} here",
crate::ui::valid_sign(),
newly.join(", "),
if newly.len() == 1 { "it" } else { "them" },
);
announced.extend(newly);
}
}
for folder in folders {
let already_here: &[&str] = if folder.is_empty() { &already } else { &[] };
if !run_gate(&where_, &folder, already_here) {
return Outcome::Failed;
}
}
}
Outcome::Passed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_gate_drops_only_what_commit_already_covers() {
let pkg = r#"{"scripts":{"typecheck":"tsc","test":"vitest run"}}"#;
assert_eq!(gate_for(pkg, &[]), vec!["typecheck", "test"]);
assert_eq!(gate_for(pkg, &["typecheck"]), vec!["test"]);
assert_eq!(gate_for(pkg, &["test"]), vec!["typecheck"]);
assert!(gate_for(pkg, &["typecheck", "test"]).is_empty());
assert_eq!(gate_for(pkg, &["test:unit"]), vec!["typecheck", "test"]);
}
#[test]
fn a_scope_covers_only_when_every_changed_file_matches() {
use crate::check::Scope;
const TS_ONLY: Scope = Scope::files(&[".ts", ".tsx"]);
let all_ts: Vec<String> = vec!["src/a.ts".into(), "src/b.tsx".into()];
let mixed: Vec<String> = vec!["src/a.ts".into(), "src/legacy.js".into()];
let js_only: Vec<String> = vec!["src/legacy.js".into()];
assert!(TS_ONLY.covers_all(&all_ts));
assert!(!TS_ONLY.covers_all(&mixed));
assert!(!TS_ONLY.covers_all(&js_only));
assert!(Scope::ALWAYS.covers_all(&mixed));
assert!(TS_ONLY.covers_all(&[]));
const BY_NAME: Scope = Scope {
files: &[],
names: &["package.json"],
opt_in: &[],
not_during: &[],
};
assert!(BY_NAME.covers_all(&["apps/web/package.json".into()]));
assert!(!BY_NAME.covers_all(&["not-package.json".into()]));
}
#[test]
fn finds_scripts_only_inside_the_scripts_object() {
let pkg = r#"{"name":"x","scripts":{"test":"vitest","typecheck":"tsc"},"devDependencies":{"lint":"1"}}"#;
assert!(defines_script(pkg, "test"));
assert!(defines_script(pkg, "typecheck"));
assert!(!defines_script(pkg, "test:unit"));
assert!(!defines_script(pkg, "lint"));
}
#[test]
fn survives_nested_objects_and_escaped_quotes() {
let pkg = r#"{"scripts":{"test":"echo \"hi\"","build":"x"},"other":{"test":"no"}}"#;
assert!(defines_script(pkg, "test"));
assert!(defines_script(pkg, "build"));
assert!(!defines_script(pkg, "other"));
}
#[test]
fn no_scripts_object_means_no_scripts() {
assert!(!defines_script(r#"{"name":"x"}"#, "test"));
}
#[test]
fn a_scripts_string_elsewhere_cannot_anchor_the_scan() {
let pkg = r#"{"files":["scripts"],"dependencies":{"test":"1.0"},"scripts":{"build":"x"}}"#;
assert!(
!defines_script(pkg, "test"),
"a dependency answered as a script"
);
assert!(
defines_script(pkg, "build"),
"the real scripts object was skipped"
);
let pkg =
r#"{"description":"scripts","dependencies":{"test":"1.0"},"scripts":{"build":"x"}}"#;
assert!(!defines_script(pkg, "test"));
assert!(defines_script(pkg, "build"));
let pkg = r#"{"config":{"scripts":{"test":"inner"}},"scripts":{"build":"x"}}"#;
assert!(!defines_script(pkg, "test"));
assert!(defines_script(pkg, "build"));
}
#[test]
fn a_non_object_scripts_value_defines_nothing() {
assert!(!defines_script(r#"{"scripts":"echo hi"}"#, "test"));
assert!(!defines_script(
r#"{"scripts":"x","other":{"test":"y"}}"#,
"test"
));
}
#[test]
fn collects_package_directories() {
let ls: Vec<String> = [
"package.json",
"apps/web/package.json",
"apps/web/src/a.ts",
"README.md",
]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
package_dirs(&ls),
vec!["".to_string(), "apps/web".to_string()]
);
}
#[test]
fn selects_only_packages_containing_a_change() {
let pkgs = vec!["apps/web".to_string(), "apps/api".to_string()];
let changed = vec!["apps/web/src".to_string()];
assert_eq!(
packages_to_test(&pkgs, &changed),
vec!["apps/web".to_string()]
);
}
#[test]
fn the_repo_root_package_matches_any_change() {
let pkgs = vec!["".to_string()];
assert_eq!(
packages_to_test(&pkgs, &["src".to_string()]),
vec!["".to_string()]
);
}
}