use super::{
layout::frontend_package_manager,
predicates::{
referenced_package_scripts, uses_javascript_linter, uses_native_dead_code_checker,
uses_native_duplicate_checker, uses_native_frontend_linter, uses_native_health_checker,
uses_strict_frontend_typechecker,
},
};
use crate::cli::{
check::{QualityCheck, first_output_line, quality_check},
project::read_package_json,
};
use serde_json::Value;
use std::{
collections::BTreeSet,
path::Path,
process::{Command, Stdio},
};
pub(super) fn frontend_script_checks(project_dir: &Path, run_scripts: bool) -> Vec<QualityCheck> {
let manifest = read_package_json(project_dir);
let is_next = super::layout::is_next_frontend(project_dir);
let typecheck = package_script_value(manifest.as_ref(), "typecheck");
let lint = package_script_value(manifest.as_ref(), "lint");
let mut checks = vec![
package_script_exists_check("typecheck", &typecheck),
package_script_exists_check("lint", &lint),
strict_typecheck_check(&typecheck, is_next),
native_lint_check(manifest.as_ref(), is_next),
native_quality_gate_check(manifest.as_ref()),
];
if run_scripts && checks.iter().all(|check| check.ok) {
checks.push(frontend_dependency_check(project_dir));
}
if run_scripts && checks.iter().all(|check| check.ok) {
checks.push(package_script_check(project_dir, "typecheck"));
checks.push(package_script_check(project_dir, "lint"));
}
checks
}
fn package_script_exists_check(script: &str, command: &str) -> QualityCheck {
quality_check(
&format!("package script {script}"),
!command.is_empty(),
"found",
"missing",
&format!("Add a non-empty \"{script}\" script to package.json, then retry."),
)
}
fn strict_typecheck_check(command: &str, is_next: bool) -> QualityCheck {
quality_check(
"strict frontend typecheck",
uses_strict_frontend_typechecker(command),
"accepted",
"native typecheck missing",
&format!(
"Set package.json `typecheck` to `oxlint {} --deny-warnings --type-aware --type-check --tsconfig tsconfig.json`, then retry.",
frontend_lint_targets(is_next)
),
)
}
fn native_lint_check(manifest: Option<&Value>, is_next: bool) -> QualityCheck {
let ok = !package_script_tree_uses(
manifest,
"lint",
uses_javascript_linter,
&mut BTreeSet::new(),
) && package_script_tree_uses(
manifest,
"lint",
uses_native_frontend_linter,
&mut BTreeSet::new(),
);
quality_check(
"native frontend lint",
ok,
"accepted",
"native linter missing",
&format!(
"Replace the lint script with native tooling such as `oxlint {} --deny-warnings` or `biome check .`, then retry.",
frontend_lint_targets(is_next)
),
)
}
fn native_quality_gate_check(manifest: Option<&Value>) -> QualityCheck {
let ok = package_script_tree_uses(
manifest,
"lint",
uses_native_dead_code_checker,
&mut BTreeSet::new(),
) && package_script_tree_uses(
manifest,
"lint",
uses_native_duplicate_checker,
&mut BTreeSet::new(),
) && package_script_tree_uses(
manifest,
"lint",
uses_native_health_checker,
&mut BTreeSet::new(),
);
quality_check(
"native frontend quality gates",
ok,
"accepted",
"dead-code, duplicate-code, or health gate missing",
"Add Fallow checks for `dead-code`, semantic `dupes`, and `health` to package.json `lint`, then retry.",
)
}
fn package_script_value(manifest: Option<&Value>, script: &str) -> String {
manifest
.and_then(|manifest| manifest.get("scripts"))
.and_then(|scripts| scripts.get(script))
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_owned()
}
fn frontend_lint_targets(is_next: bool) -> &'static str {
if is_next {
"app next.config.mjs"
} else {
"src vite.config.ts"
}
}
fn package_script_tree_uses(
manifest: Option<&Value>,
script: &str,
predicate: fn(&str) -> bool,
seen: &mut BTreeSet<String>,
) -> bool {
if !seen.insert(script.to_owned()) {
return false;
}
let command = package_script_value(manifest, script);
if command.is_empty() {
return false;
}
if predicate(&command) {
return true;
}
referenced_package_scripts(&command)
.iter()
.any(|referenced| package_script_tree_uses(manifest, referenced, predicate, seen))
}
fn frontend_dependency_check(project_dir: &Path) -> QualityCheck {
if project_dir.join("node_modules").exists() {
return QualityCheck {
name: "frontend dependencies".to_owned(),
ok: true,
message: "installed".to_owned(),
agent_instruction: None,
};
}
let manager = frontend_package_manager(project_dir);
let args = frontend_install_args(manager);
let result = Command::new(manager)
.args(&args)
.current_dir(project_dir)
.stdin(Stdio::null())
.output();
let output = match result {
Ok(output) => output,
Err(error) => {
return QualityCheck {
name: format!("{manager} install"),
ok: false,
message: error.to_string(),
agent_instruction: Some(format!(
"Install {}, then rerun `tovuk check`.",
if manager == "bun" {
"Bun"
} else {
"Node.js and npm"
}
)),
};
}
};
QualityCheck {
name: format!("{manager} install"),
ok: output.status.success(),
message: if output.status.success() {
"passed".to_owned()
} else {
first_output_line(
&output.stderr,
&output.stdout,
&format!("{manager} install"),
)
},
agent_instruction: if output.status.success() {
None
} else {
Some(format!(
"Run `{manager} {}` in the frontend directory, fix dependency installation, then rerun `tovuk check`.",
args.join(" ")
))
},
}
}
fn frontend_install_args(manager: &str) -> Vec<&'static str> {
if manager == "bun" {
vec!["ci"]
} else {
vec!["ci", "--prefer-offline", "--no-audit", "--fund=false"]
}
}
fn package_script_check(project_dir: &Path, script: &str) -> QualityCheck {
let manager = frontend_package_manager(project_dir);
let args = if manager == "bun" {
vec!["run", script]
} else {
vec!["run", "--silent", script]
};
let result = Command::new(manager)
.args(args)
.current_dir(project_dir)
.stdin(Stdio::null())
.output();
let output = match result {
Ok(output) => output,
Err(error) => {
return QualityCheck {
name: format!("{manager} run {script}"),
ok: false,
message: error.to_string(),
agent_instruction: Some(format!(
"Install {}, then run `{manager} run {script}` before deploying.",
if manager == "bun" {
"Bun"
} else {
"Node.js and npm"
}
)),
};
}
};
QualityCheck {
name: format!("{manager} run {script}"),
ok: output.status.success(),
message: if output.status.success() {
"passed".to_owned()
} else {
first_output_line(
&output.stderr,
&output.stdout,
&format!("{manager} run {script}"),
)
},
agent_instruction: if output.status.success() {
None
} else {
Some(format!(
"Run `{manager} run {script}`, fix every error, then redeploy."
))
},
}
}
#[cfg(test)]
mod tests {
use super::frontend_install_args;
#[test]
fn frontend_install_args_match_template_checks() {
assert_eq!(
frontend_install_args("npm"),
vec!["ci", "--prefer-offline", "--no-audit", "--fund=false"]
);
assert_eq!(frontend_install_args("bun"), vec!["ci"]);
}
}