mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
1102 lines
42 KiB
Rust
1102 lines
42 KiB
Rust
use crate::config_types::{validation_tool_category, GithubConfig, ValidationCategory, ValidationConfig};
|
|
use crate::workflow_validation::maybe_run_actionlint;
|
|
use code_apply_patch::{ApplyPatchAction, ApplyPatchFileChange};
|
|
use serde_json as json;
|
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::ExitStatus;
|
|
use tempfile::TempDir;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct HarnessFinding {
|
|
pub tool: String,
|
|
pub file: Option<PathBuf>,
|
|
pub message: String,
|
|
}
|
|
|
|
/// Run fast validations on the files touched by a patch. Returns `None` when the
|
|
/// harness is disabled and no checks were executed.
|
|
pub fn run_patch_harness(
|
|
action: &ApplyPatchAction,
|
|
cwd: &Path,
|
|
cfg: &ValidationConfig,
|
|
github: &GithubConfig,
|
|
) -> Option<(Vec<HarnessFinding>, Vec<String>)> {
|
|
let functional_enabled = cfg.groups.functional;
|
|
let stylistic_enabled = cfg.groups.stylistic;
|
|
|
|
if !functional_enabled && !stylistic_enabled {
|
|
return None;
|
|
}
|
|
|
|
let mut findings: Vec<HarnessFinding> = Vec::new();
|
|
let mut ran: Vec<String> = Vec::new();
|
|
let mut record_ran = |name: &str| {
|
|
if !ran.iter().any(|existing| existing == name) {
|
|
ran.push(name.to_string());
|
|
}
|
|
};
|
|
|
|
let category_enabled = |category: ValidationCategory| -> bool {
|
|
match category {
|
|
ValidationCategory::Functional => functional_enabled,
|
|
ValidationCategory::Stylistic => stylistic_enabled,
|
|
}
|
|
};
|
|
|
|
// 1) Built-in structural parses (JSON/TOML/YAML).
|
|
for (path, change) in action.changes() {
|
|
let (analysis_path, contents_opt) = match change {
|
|
ApplyPatchFileChange::Add { content } => (path.as_path(), Some(content)),
|
|
ApplyPatchFileChange::Update { new_content, move_path, .. } => (
|
|
move_path.as_ref().map_or(path.as_path(), |dest| dest.as_path()),
|
|
Some(new_content),
|
|
),
|
|
ApplyPatchFileChange::Delete { .. } => (path.as_path(), None),
|
|
};
|
|
|
|
if !functional_enabled {
|
|
continue;
|
|
}
|
|
let Some(contents) = contents_opt else { continue };
|
|
match analysis_path.extension().and_then(|e| e.to_str()).unwrap_or("") {
|
|
"json" => {
|
|
record_ran("json-parse");
|
|
if let Err(err) = json::from_str::<json::Value>(contents) {
|
|
findings.push(HarnessFinding {
|
|
tool: "json-parse".to_string(),
|
|
file: Some(analysis_path.to_path_buf()),
|
|
message: format!("invalid JSON: {err}"),
|
|
});
|
|
}
|
|
}
|
|
"toml" => {
|
|
record_ran("toml-parse");
|
|
if let Err(err) = toml::from_str::<toml::Value>(contents) {
|
|
findings.push(HarnessFinding {
|
|
tool: "toml-parse".to_string(),
|
|
file: Some(analysis_path.to_path_buf()),
|
|
message: format!("invalid TOML: {err}"),
|
|
});
|
|
}
|
|
}
|
|
"yml" | "yaml" => {
|
|
record_ran("yaml-parse");
|
|
if let Err(err) = serde_yaml::from_str::<serde_yaml::Value>(contents) {
|
|
findings.push(HarnessFinding {
|
|
tool: "yaml-parse".to_string(),
|
|
file: Some(analysis_path.to_path_buf()),
|
|
message: format!("invalid YAML: {err}"),
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// 2) Workflow checks (actionlint plugin).
|
|
if functional_enabled {
|
|
if let Some(lines) = maybe_run_actionlint(action, cwd, github) {
|
|
if !lines.is_empty() {
|
|
record_ran("actionlint");
|
|
for line in lines.into_iter().take(24) {
|
|
findings.push(HarnessFinding { tool: "actionlint".to_string(), file: None, message: line });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) External tools (shellcheck, markdownlint, etc.).
|
|
let allow = cfg.tools_allowlist.clone().unwrap_or_default();
|
|
let timeout = cfg.timeout_seconds.unwrap_or(6);
|
|
|
|
// Stage touched files into a temporary workspace so external tools can run safely.
|
|
let temp = TempDir::new().ok()?;
|
|
let staged_root = temp.path();
|
|
|
|
let mut changed_paths: Vec<PathBuf> = Vec::new();
|
|
for (path, change) in action.changes() {
|
|
match change {
|
|
ApplyPatchFileChange::Add { content } => {
|
|
if let Some(rel) = stage_file(staged_root, cwd, path, content) {
|
|
changed_paths.push(rel);
|
|
}
|
|
}
|
|
ApplyPatchFileChange::Update { new_content, move_path, .. } => {
|
|
let dest_path = move_path.as_ref().unwrap_or(path);
|
|
if let Some(rel) = stage_file(staged_root, cwd, dest_path, new_content) {
|
|
changed_paths.push(rel);
|
|
}
|
|
if move_path.is_some() && move_path.as_ref().map(|p| p.as_path()) != Some(path.as_path()) {
|
|
remove_staged_file(staged_root, cwd, path);
|
|
}
|
|
}
|
|
ApplyPatchFileChange::Delete { .. } => {
|
|
remove_staged_file(staged_root, cwd, path);
|
|
}
|
|
}
|
|
}
|
|
|
|
changed_paths.sort();
|
|
changed_paths.dedup();
|
|
|
|
let is_allowed = |tool: &str| allow.is_empty() || allow.iter().any(|entry| entry == tool);
|
|
let run_tool = |tool: &str, args: &[&str], files: &[PathBuf], group_enabled: bool| -> Vec<HarnessFinding> {
|
|
if !group_enabled || files.is_empty() || !is_allowed(tool) {
|
|
return Vec::new();
|
|
}
|
|
let Some(exe) = which(Path::new(tool)) else { return Vec::new() };
|
|
let mut cmd = std::process::Command::new(exe);
|
|
cmd.current_dir(staged_root);
|
|
cmd.args(args);
|
|
cmd.args(files);
|
|
match run_with_timeout(cmd, timeout) {
|
|
Some(output) => collect_output_lines(&output.stdout, &output.stderr)
|
|
.into_iter()
|
|
.map(|message| HarnessFinding { tool: tool.to_string(), file: None, message })
|
|
.collect(),
|
|
None => vec![HarnessFinding {
|
|
tool: tool.to_string(),
|
|
file: None,
|
|
message: format!("{tool} timed out after {timeout} second(s)"),
|
|
}],
|
|
}
|
|
};
|
|
|
|
let shell_scripts: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| is_shell_script(staged_root, path))
|
|
.cloned()
|
|
.collect();
|
|
let shellcheck_group = validation_tool_category("shellcheck");
|
|
let shellcheck_group_enabled = category_enabled(shellcheck_group);
|
|
if shellcheck_group_enabled && cfg.tools.shellcheck.unwrap_or(true) && !shell_scripts.is_empty() {
|
|
if which(Path::new("shellcheck")).is_some() {
|
|
record_ran("shellcheck");
|
|
}
|
|
findings.extend(run_tool("shellcheck", &["-f", "gcc"], &shell_scripts, shellcheck_group_enabled));
|
|
}
|
|
|
|
let markdown_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
|
|
.cloned()
|
|
.collect();
|
|
let markdownlint_group = validation_tool_category("markdownlint");
|
|
let markdownlint_group_enabled = category_enabled(markdownlint_group);
|
|
if markdownlint_group_enabled && cfg.tools.markdownlint.unwrap_or(true) && !markdown_files.is_empty() {
|
|
if which(Path::new("markdownlint")).is_some() || which(Path::new("markdownlint-cli2")).is_some() {
|
|
record_ran("markdownlint");
|
|
}
|
|
let mut lines = run_tool("markdownlint", &[], &markdown_files, markdownlint_group_enabled);
|
|
if lines.is_empty() {
|
|
lines = run_tool("markdownlint-cli2", &[], &markdown_files, markdownlint_group_enabled);
|
|
}
|
|
findings.extend(lines);
|
|
}
|
|
|
|
let docker_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| is_dockerfile(path))
|
|
.cloned()
|
|
.collect();
|
|
let hadolint_group = validation_tool_category("hadolint");
|
|
let hadolint_group_enabled = category_enabled(hadolint_group);
|
|
if hadolint_group_enabled && cfg.tools.hadolint.unwrap_or(true) && !docker_files.is_empty() {
|
|
if which(Path::new("hadolint")).is_some() {
|
|
record_ran("hadolint");
|
|
}
|
|
findings.extend(run_tool("hadolint", &[], &docker_files, hadolint_group_enabled));
|
|
}
|
|
|
|
let yaml_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| matches!(path.extension().and_then(|ext| ext.to_str()), Some("yml" | "yaml")))
|
|
.cloned()
|
|
.collect();
|
|
let yamllint_group = validation_tool_category("yamllint");
|
|
let yamllint_group_enabled = category_enabled(yamllint_group);
|
|
if yamllint_group_enabled && cfg.tools.yamllint.unwrap_or(true) && !yaml_files.is_empty() {
|
|
if which(Path::new("yamllint")).is_some() {
|
|
record_ran("yamllint");
|
|
}
|
|
findings.extend(run_tool("yamllint", &["-f", "parsable"], &yaml_files, yamllint_group_enabled));
|
|
}
|
|
|
|
let rust_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("rs"))
|
|
.cloned()
|
|
.collect();
|
|
|
|
let shfmt_group = validation_tool_category("shfmt");
|
|
let shfmt_group_enabled = category_enabled(shfmt_group);
|
|
if shfmt_group_enabled && cfg.tools.shfmt.unwrap_or(true) && !shell_scripts.is_empty() {
|
|
if which(Path::new("shfmt")).is_some() {
|
|
record_ran("shfmt");
|
|
}
|
|
findings.extend(run_tool("shfmt", &["-d"], &shell_scripts, shfmt_group_enabled));
|
|
}
|
|
|
|
let prettier_exts = [
|
|
"js", "jsx", "ts", "tsx", "json", "css", "scss", "less", "html", "yml", "yaml",
|
|
];
|
|
let prettier_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| path
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.map(|ext| prettier_exts.contains(&ext))
|
|
.unwrap_or(false))
|
|
.cloned()
|
|
.collect();
|
|
let prettier_group = validation_tool_category("prettier");
|
|
let prettier_group_enabled = category_enabled(prettier_group);
|
|
if prettier_group_enabled && cfg.tools.prettier.unwrap_or(true) && !prettier_files.is_empty() {
|
|
if which(Path::new("prettier")).is_some() {
|
|
record_ran("prettier");
|
|
}
|
|
findings.extend(run_tool("prettier", &["--check"], &prettier_files, prettier_group_enabled));
|
|
}
|
|
|
|
let ts_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts" | "tsx")))
|
|
.cloned()
|
|
.collect();
|
|
if functional_enabled && cfg.tools.tsc.unwrap_or(true) && !ts_files.is_empty() && is_allowed("tsc") {
|
|
if let Some(exe) = which(Path::new("tsc")) {
|
|
record_ran("tsc");
|
|
let ts_timeout = timeout.max(20);
|
|
let project = find_nearest_config(cwd, &ts_files, &["tsconfig.json", "tsconfig.base.json", "tsconfig.app.json", "tsconfig.build.json", "tsconfig.lib.json"]);
|
|
match WorkspaceOverlay::apply(action) {
|
|
Ok(_overlay) => {
|
|
let mut cmd = std::process::Command::new(&exe);
|
|
cmd.current_dir(cwd);
|
|
cmd.arg("--noEmit");
|
|
cmd.arg("--pretty");
|
|
cmd.arg("false");
|
|
if let Some(config) = project {
|
|
cmd.arg("--project");
|
|
cmd.arg(config);
|
|
} else {
|
|
for path in &ts_files {
|
|
cmd.arg(path);
|
|
}
|
|
}
|
|
match run_with_timeout(cmd, ts_timeout) {
|
|
Some(output) => {
|
|
if output.status.map_or(true, |status| !status.success()) {
|
|
let mut lines = collect_output_lines(&output.stdout, &output.stderr);
|
|
if lines.is_empty() {
|
|
lines.push("tsc failed (no output)".to_string());
|
|
}
|
|
for line in lines.into_iter().take(24) {
|
|
findings.push(HarnessFinding { tool: "tsc".to_string(), file: None, message: line });
|
|
}
|
|
}
|
|
}
|
|
None => findings.push(HarnessFinding {
|
|
tool: "tsc".to_string(),
|
|
file: None,
|
|
message: format!("tsc timed out after {ts_timeout} second(s)"),
|
|
}),
|
|
}
|
|
}
|
|
Err(err) => findings.push(HarnessFinding {
|
|
tool: "tsc".to_string(),
|
|
file: None,
|
|
message: format!("failed to stage workspace for tsc: {err}"),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
let eslint_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| matches!(path.extension().and_then(|ext| ext.to_str()), Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs")))
|
|
.cloned()
|
|
.collect();
|
|
if functional_enabled
|
|
&& cfg.tools.eslint.unwrap_or(true)
|
|
&& !eslint_files.is_empty()
|
|
&& is_allowed("eslint")
|
|
&& has_eslint_config(cwd, &eslint_files)
|
|
{
|
|
if let Some(exe) = which(Path::new("eslint")) {
|
|
record_ran("eslint");
|
|
let lint_timeout = timeout.max(15);
|
|
match WorkspaceOverlay::apply(action) {
|
|
Ok(_overlay) => {
|
|
let mut cmd = std::process::Command::new(&exe);
|
|
cmd.current_dir(cwd);
|
|
cmd.args(["--max-warnings", "0", "--format", "unix"]);
|
|
for path in &eslint_files {
|
|
cmd.arg(path);
|
|
}
|
|
match run_with_timeout(cmd, lint_timeout) {
|
|
Some(output) => {
|
|
if output.status.map_or(true, |status| !status.success()) {
|
|
let mut lines = collect_output_lines(&output.stdout, &output.stderr);
|
|
if lines.is_empty() {
|
|
lines.push("eslint failed (no output)".to_string());
|
|
}
|
|
for line in lines.into_iter().take(24) {
|
|
findings.push(HarnessFinding { tool: "eslint".to_string(), file: None, message: line });
|
|
}
|
|
}
|
|
}
|
|
None => findings.push(HarnessFinding {
|
|
tool: "eslint".to_string(),
|
|
file: None,
|
|
message: format!("eslint timed out after {lint_timeout} second(s)"),
|
|
}),
|
|
}
|
|
}
|
|
Err(err) => findings.push(HarnessFinding {
|
|
tool: "eslint".to_string(),
|
|
file: None,
|
|
message: format!("failed to stage workspace for eslint: {err}"),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
let php_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("php"))
|
|
.cloned()
|
|
.collect();
|
|
if functional_enabled
|
|
&& cfg.tools.phpstan.unwrap_or(true)
|
|
&& !php_files.is_empty()
|
|
&& is_allowed("phpstan")
|
|
&& has_phpstan_config(cwd, &php_files)
|
|
{
|
|
if let Some(exe) = which(Path::new("phpstan")) {
|
|
record_ran("phpstan");
|
|
let phpstan_timeout = timeout.max(20);
|
|
match WorkspaceOverlay::apply(action) {
|
|
Ok(_overlay) => {
|
|
let mut cmd = std::process::Command::new(&exe);
|
|
cmd.current_dir(cwd);
|
|
cmd.args(["analyse", "--error-format=raw", "--no-progress"]);
|
|
for path in &php_files {
|
|
cmd.arg(path);
|
|
}
|
|
match run_with_timeout(cmd, phpstan_timeout) {
|
|
Some(output) => {
|
|
if output.status.map_or(true, |status| !status.success()) {
|
|
let mut lines = collect_output_lines(&output.stdout, &output.stderr);
|
|
if lines.is_empty() {
|
|
lines.push("phpstan failed (no output)".to_string());
|
|
}
|
|
for line in lines.into_iter().take(24) {
|
|
findings.push(HarnessFinding { tool: "phpstan".to_string(), file: None, message: line });
|
|
}
|
|
}
|
|
}
|
|
None => findings.push(HarnessFinding {
|
|
tool: "phpstan".to_string(),
|
|
file: None,
|
|
message: format!("phpstan timed out after {phpstan_timeout} second(s)"),
|
|
}),
|
|
}
|
|
}
|
|
Err(err) => findings.push(HarnessFinding {
|
|
tool: "phpstan".to_string(),
|
|
file: None,
|
|
message: format!("failed to stage workspace for phpstan: {err}"),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
if functional_enabled
|
|
&& cfg.tools.psalm.unwrap_or(true)
|
|
&& !php_files.is_empty()
|
|
&& is_allowed("psalm")
|
|
&& has_psalm_config(cwd, &php_files)
|
|
{
|
|
if let Some(exe) = which(Path::new("psalm")) {
|
|
record_ran("psalm");
|
|
let psalm_timeout = timeout.max(20);
|
|
match WorkspaceOverlay::apply(action) {
|
|
Ok(_overlay) => {
|
|
let mut cmd = std::process::Command::new(&exe);
|
|
cmd.current_dir(cwd);
|
|
cmd.args(["--no-progress", "--output-format=compact", "--threads=2"]);
|
|
for path in &php_files {
|
|
cmd.arg(path);
|
|
}
|
|
match run_with_timeout(cmd, psalm_timeout) {
|
|
Some(output) => {
|
|
if output.status.map_or(true, |status| !status.success()) {
|
|
let mut lines = collect_output_lines(&output.stdout, &output.stderr);
|
|
if lines.is_empty() {
|
|
lines.push("psalm failed (no output)".to_string());
|
|
}
|
|
for line in lines.into_iter().take(24) {
|
|
findings.push(HarnessFinding { tool: "psalm".to_string(), file: None, message: line });
|
|
}
|
|
}
|
|
}
|
|
None => findings.push(HarnessFinding {
|
|
tool: "psalm".to_string(),
|
|
file: None,
|
|
message: format!("psalm timed out after {psalm_timeout} second(s)"),
|
|
}),
|
|
}
|
|
}
|
|
Err(err) => findings.push(HarnessFinding {
|
|
tool: "psalm".to_string(),
|
|
file: None,
|
|
message: format!("failed to stage workspace for psalm: {err}"),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
let py_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("py"))
|
|
.cloned()
|
|
.collect();
|
|
if functional_enabled && cfg.tools.mypy.unwrap_or(true) && !py_files.is_empty() && is_allowed("mypy") {
|
|
if let Some(exe) = which(Path::new("mypy")) {
|
|
record_ran("mypy");
|
|
let mypy_timeout = timeout.max(20);
|
|
match WorkspaceOverlay::apply(action) {
|
|
Ok(_overlay) => {
|
|
let mut cmd = std::process::Command::new(&exe);
|
|
cmd.current_dir(cwd);
|
|
cmd.args(["--no-color-output", "--hide-error-context"]);
|
|
for path in &py_files {
|
|
cmd.arg(path);
|
|
}
|
|
match run_with_timeout(cmd, mypy_timeout) {
|
|
Some(output) => {
|
|
if output.status.map_or(true, |status| !status.success()) {
|
|
let mut lines = collect_output_lines(&output.stdout, &output.stderr);
|
|
if lines.is_empty() {
|
|
lines.push("mypy failed (no output)".to_string());
|
|
}
|
|
for line in lines.into_iter().take(24) {
|
|
findings.push(HarnessFinding { tool: "mypy".to_string(), file: None, message: line });
|
|
}
|
|
}
|
|
}
|
|
None => findings.push(HarnessFinding {
|
|
tool: "mypy".to_string(),
|
|
file: None,
|
|
message: format!("mypy timed out after {mypy_timeout} second(s)"),
|
|
}),
|
|
}
|
|
}
|
|
Err(err) => findings.push(HarnessFinding {
|
|
tool: "mypy".to_string(),
|
|
file: None,
|
|
message: format!("failed to stage workspace for mypy: {err}"),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
if functional_enabled && cfg.tools.pyright.unwrap_or(true) && !py_files.is_empty() && is_allowed("pyright") {
|
|
if let Some(exe) = which(Path::new("pyright")) {
|
|
record_ran("pyright");
|
|
let pyright_timeout = timeout.max(20);
|
|
match WorkspaceOverlay::apply(action) {
|
|
Ok(_overlay) => {
|
|
let mut cmd = std::process::Command::new(&exe);
|
|
cmd.current_dir(cwd);
|
|
cmd.arg("--warnings");
|
|
for path in &py_files {
|
|
cmd.arg(path);
|
|
}
|
|
match run_with_timeout(cmd, pyright_timeout) {
|
|
Some(output) => {
|
|
if output.status.map_or(true, |status| !status.success()) {
|
|
let mut lines = collect_output_lines(&output.stdout, &output.stderr);
|
|
if lines.is_empty() {
|
|
lines.push("pyright failed (no output)".to_string());
|
|
}
|
|
for line in lines.into_iter().take(24) {
|
|
findings.push(HarnessFinding { tool: "pyright".to_string(), file: None, message: line });
|
|
}
|
|
}
|
|
}
|
|
None => findings.push(HarnessFinding {
|
|
tool: "pyright".to_string(),
|
|
file: None,
|
|
message: format!("pyright timed out after {pyright_timeout} second(s)"),
|
|
}),
|
|
}
|
|
}
|
|
Err(err) => findings.push(HarnessFinding {
|
|
tool: "pyright".to_string(),
|
|
file: None,
|
|
message: format!("failed to stage workspace for pyright: {err}"),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
let go_files: Vec<PathBuf> = changed_paths
|
|
.iter()
|
|
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("go"))
|
|
.cloned()
|
|
.collect();
|
|
if functional_enabled
|
|
&& cfg.tools.golangci_lint.unwrap_or(true)
|
|
&& !go_files.is_empty()
|
|
&& is_allowed("golangci-lint")
|
|
&& has_go_module(cwd)
|
|
{
|
|
if let Some(exe) = which(Path::new("golangci-lint")) {
|
|
record_ran("golangci-lint");
|
|
let lint_timeout = timeout.max(20);
|
|
match WorkspaceOverlay::apply(action) {
|
|
Ok(_overlay) => {
|
|
let mut cmd = std::process::Command::new(&exe);
|
|
cmd.current_dir(cwd);
|
|
cmd.args(["run", "./..."]);
|
|
match run_with_timeout(cmd, lint_timeout) {
|
|
Some(output) => {
|
|
if output.status.map_or(true, |status| !status.success()) {
|
|
let mut lines = collect_output_lines(&output.stdout, &output.stderr);
|
|
if lines.is_empty() {
|
|
lines.push("golangci-lint failed (no output)".to_string());
|
|
}
|
|
for line in lines.into_iter().take(24) {
|
|
findings.push(HarnessFinding { tool: "golangci-lint".to_string(), file: None, message: line });
|
|
}
|
|
}
|
|
}
|
|
None => findings.push(HarnessFinding {
|
|
tool: "golangci-lint".to_string(),
|
|
file: None,
|
|
message: format!("golangci-lint timed out after {lint_timeout} second(s)"),
|
|
}),
|
|
}
|
|
}
|
|
Err(err) => findings.push(HarnessFinding {
|
|
tool: "golangci-lint".to_string(),
|
|
file: None,
|
|
message: format!("failed to stage workspace for golangci-lint: {err}"),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
if functional_enabled && cfg.tools.cargo_check.unwrap_or(true) && !rust_files.is_empty() {
|
|
if which(Path::new("cargo")).is_none() {
|
|
findings.push(HarnessFinding {
|
|
tool: "cargo-check".to_string(),
|
|
file: None,
|
|
message: "cargo executable not found; install the Rust toolchain".to_string(),
|
|
});
|
|
} else {
|
|
match WorkspaceOverlay::apply(action) {
|
|
Ok(overlay) => {
|
|
let manifests = collect_rust_manifests(cwd, &rust_files);
|
|
let manifest_hints = compute_rust_target_hints(cwd, &rust_files);
|
|
let rust_timeout = timeout.max(30);
|
|
for manifest in manifests {
|
|
let label = manifest
|
|
.strip_prefix(cwd)
|
|
.unwrap_or(&manifest)
|
|
.display()
|
|
.to_string();
|
|
let mut cmd = std::process::Command::new("cargo");
|
|
cmd.current_dir(cwd);
|
|
cmd.arg("check");
|
|
cmd.arg("--quiet");
|
|
let hints = manifest_hints.get(&manifest).copied().unwrap_or_default();
|
|
// `cargo check` does not support `--no-dev-deps`; compiling dev deps is
|
|
// avoided by limiting targets instead.
|
|
if hints.include_tests {
|
|
cmd.arg("--tests");
|
|
}
|
|
if hints.include_benches {
|
|
cmd.arg("--benches");
|
|
}
|
|
if hints.include_examples {
|
|
cmd.arg("--examples");
|
|
}
|
|
cmd.arg("--manifest-path");
|
|
cmd.arg(manifest.to_string_lossy().to_string());
|
|
cmd.env("RUSTFLAGS", "-Dwarnings");
|
|
|
|
match run_with_timeout(cmd, rust_timeout) {
|
|
Some(output) => {
|
|
record_ran(&format!("cargo-check({label})"));
|
|
if output.status.map_or(true, |status| !status.success()) {
|
|
let mut lines = collect_output_lines(&output.stdout, &output.stderr);
|
|
if lines.is_empty() {
|
|
lines.push("cargo check failed (no output)".to_string());
|
|
}
|
|
for line in lines.into_iter().take(24) {
|
|
findings.push(HarnessFinding {
|
|
tool: format!("cargo-check({label})"),
|
|
file: None,
|
|
message: line,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
findings.push(HarnessFinding {
|
|
tool: format!("cargo-check({label})"),
|
|
file: None,
|
|
message: format!(
|
|
"cargo check timed out after {rust_timeout} second(s)"
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
drop(overlay);
|
|
}
|
|
Err(err) => {
|
|
findings.push(HarnessFinding {
|
|
tool: "cargo-check".to_string(),
|
|
file: None,
|
|
message: format!("failed to stage workspace for cargo check: {err}"),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if findings.is_empty() && ran.is_empty() {
|
|
None
|
|
} else {
|
|
Some((findings, ran))
|
|
}
|
|
}
|
|
|
|
fn is_shell_script(staged_root: &Path, relative: &Path) -> bool {
|
|
match relative.extension().and_then(|ext| ext.to_str()) {
|
|
Some("sh") => true,
|
|
_ => {
|
|
let staged = staged_root.join(relative);
|
|
std::fs::read(staged)
|
|
.ok()
|
|
.and_then(|bytes| String::from_utf8(bytes).ok())
|
|
.map(|contents| contents.starts_with("#!/"))
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_dockerfile(path: &Path) -> bool {
|
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
|
|
name.eq_ignore_ascii_case("Dockerfile") || name.starts_with("Dockerfile.")
|
|
}
|
|
|
|
fn which(exe: &Path) -> Option<PathBuf> {
|
|
if exe.is_absolute() {
|
|
return exe.exists().then(|| exe.to_path_buf());
|
|
}
|
|
let name = exe.as_os_str();
|
|
let paths: Vec<PathBuf> = std::env::var_os("PATH")
|
|
.map(|p| std::env::split_paths(&p).collect())
|
|
.unwrap_or_else(Vec::new);
|
|
for dir in paths {
|
|
let candidate = dir.join(name);
|
|
if candidate.is_file() {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn run_with_timeout(mut cmd: std::process::Command, timeout_secs: u64) -> Option<CommandCapture> {
|
|
use std::process::Stdio;
|
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
|
let Ok(mut child) = cmd.spawn() else { return None };
|
|
|
|
let start = std::time::Instant::now();
|
|
loop {
|
|
if let Some(status) = child.try_wait().ok().flatten() {
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
if let Some(mut out) = child.stdout.take() {
|
|
let _ = std::io::Read::read_to_end(&mut out, &mut stdout);
|
|
}
|
|
if let Some(mut err) = child.stderr.take() {
|
|
let _ = std::io::Read::read_to_end(&mut err, &mut stderr);
|
|
}
|
|
return Some(CommandCapture { status: Some(status), stdout, stderr });
|
|
}
|
|
|
|
if start.elapsed().as_secs() >= timeout_secs {
|
|
let _ = child.kill();
|
|
return None;
|
|
}
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(40));
|
|
}
|
|
}
|
|
|
|
fn stage_file(staged_root: &Path, cwd: &Path, path: &Path, contents: &str) -> Option<PathBuf> {
|
|
let relative = path.strip_prefix(cwd).ok()?;
|
|
let dest = staged_root.join(relative);
|
|
if let Some(parent) = dest.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
}
|
|
if let Ok(mut file) = fs::File::create(&dest) {
|
|
if file.write_all(contents.as_bytes()).is_ok() {
|
|
return Some(relative.to_path_buf());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn remove_staged_file(staged_root: &Path, cwd: &Path, path: &Path) {
|
|
if let Ok(relative) = path.strip_prefix(cwd) {
|
|
let dest = staged_root.join(relative);
|
|
let _ = fs::remove_file(dest);
|
|
}
|
|
}
|
|
|
|
fn collect_output_lines(stdout: &[u8], stderr: &[u8]) -> Vec<String> {
|
|
let mut lines: Vec<String> = Vec::new();
|
|
if !stdout.is_empty() {
|
|
lines.extend(String::from_utf8_lossy(stdout).lines().map(|s| s.to_string()));
|
|
}
|
|
if !stderr.is_empty() {
|
|
lines.extend(String::from_utf8_lossy(stderr).lines().map(|s| s.to_string()));
|
|
}
|
|
lines.retain(|line| !line.trim().is_empty());
|
|
lines
|
|
}
|
|
|
|
#[derive(Default, Clone, Copy)]
|
|
struct RustTargetHints {
|
|
include_tests: bool,
|
|
include_benches: bool,
|
|
include_examples: bool,
|
|
}
|
|
|
|
impl RustTargetHints {
|
|
fn observe_path(&mut self, path: &Path) {
|
|
if touches_tests(path) {
|
|
self.include_tests = true;
|
|
}
|
|
if touches_benches(path) {
|
|
self.include_benches = true;
|
|
}
|
|
if touches_examples(path) {
|
|
self.include_examples = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compute_rust_target_hints(
|
|
cwd: &Path,
|
|
rust_files: &[PathBuf],
|
|
) -> HashMap<PathBuf, RustTargetHints> {
|
|
let mut hints: HashMap<PathBuf, RustTargetHints> = HashMap::new();
|
|
for relative in rust_files {
|
|
if let Some(manifest) = find_manifest(cwd, relative) {
|
|
hints.entry(manifest).or_default().observe_path(relative);
|
|
}
|
|
}
|
|
hints
|
|
}
|
|
|
|
fn touches_tests(path: &Path) -> bool {
|
|
if path.iter().filter_map(|segment| segment.to_str()).any(|segment| {
|
|
matches_segment(segment, &["tests", "test", "integration-tests", "integration_tests"])
|
|
}) {
|
|
return true;
|
|
}
|
|
matches_stem(path, &["test", "tests"], &["_test", "_tests"])
|
|
}
|
|
|
|
fn touches_benches(path: &Path) -> bool {
|
|
if path
|
|
.iter()
|
|
.filter_map(|segment| segment.to_str())
|
|
.any(|segment| matches_segment(segment, &["benches", "bench", "benchmark"]))
|
|
{
|
|
return true;
|
|
}
|
|
matches_stem(path, &["bench", "benches"], &["_bench", "_benches"])
|
|
}
|
|
|
|
fn touches_examples(path: &Path) -> bool {
|
|
if path
|
|
.iter()
|
|
.filter_map(|segment| segment.to_str())
|
|
.any(|segment| matches_segment(segment, &["examples", "example"]))
|
|
{
|
|
return true;
|
|
}
|
|
matches_stem(path, &["example", "examples"], &["_example", "_examples"])
|
|
}
|
|
|
|
fn matches_segment(segment: &str, needles: &[&str]) -> bool {
|
|
needles
|
|
.iter()
|
|
.any(|needle| segment.eq_ignore_ascii_case(needle))
|
|
}
|
|
|
|
fn matches_stem(path: &Path, exact: &[&str], suffixes: &[&str]) -> bool {
|
|
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { return false };
|
|
let stem_lower = stem.to_ascii_lowercase();
|
|
if exact.iter().any(|needle| stem_lower == *needle) {
|
|
return true;
|
|
}
|
|
suffixes.iter().any(|suffix| stem_lower.ends_with(suffix))
|
|
}
|
|
|
|
fn find_nearest_config(cwd: &Path, files: &[PathBuf], candidates: &[&str]) -> Option<PathBuf> {
|
|
for relative in files {
|
|
let mut current = cwd.join(relative).parent().map(Path::to_path_buf);
|
|
while let Some(dir) = current {
|
|
for candidate in candidates {
|
|
let candidate_path = dir.join(candidate);
|
|
if candidate_path.exists() {
|
|
return Some(candidate_path);
|
|
}
|
|
}
|
|
if dir == cwd {
|
|
break;
|
|
}
|
|
current = dir.parent().map(Path::to_path_buf);
|
|
}
|
|
}
|
|
for candidate in candidates {
|
|
let candidate_path = cwd.join(candidate);
|
|
if candidate_path.exists() {
|
|
return Some(candidate_path);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn package_json_has_key(path: &Path, key: &str) -> bool {
|
|
let Ok(contents) = std::fs::read_to_string(path) else { return false };
|
|
let Ok(value) = json::from_str::<json::Value>(&contents) else { return false };
|
|
value.get(key).is_some()
|
|
}
|
|
|
|
fn composer_requires_package(path: &Path, package: &str) -> bool {
|
|
let Ok(contents) = std::fs::read_to_string(path) else { return false };
|
|
let Ok(value) = json::from_str::<json::Value>(&contents) else { return false };
|
|
for section in ["require", "require-dev"] {
|
|
if value
|
|
.get(section)
|
|
.and_then(|deps| deps.get(package))
|
|
.is_some()
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn has_eslint_config(cwd: &Path, files: &[PathBuf]) -> bool {
|
|
let config_candidates = [
|
|
".eslintrc",
|
|
".eslintrc.js",
|
|
".eslintrc.cjs",
|
|
".eslintrc.mjs",
|
|
".eslintrc.json",
|
|
".eslintrc.yml",
|
|
".eslintrc.yaml",
|
|
"eslint.config.js",
|
|
"eslint.config.cjs",
|
|
"eslint.config.mjs",
|
|
"eslint.config.ts",
|
|
];
|
|
if find_nearest_config(cwd, files, &config_candidates).is_some() {
|
|
return true;
|
|
}
|
|
if let Some(package_json) = find_nearest_config(cwd, files, &["package.json"]) {
|
|
return package_json_has_key(&package_json, "eslintConfig");
|
|
}
|
|
false
|
|
}
|
|
|
|
fn has_phpstan_config(cwd: &Path, files: &[PathBuf]) -> bool {
|
|
if find_nearest_config(cwd, files, &["phpstan.neon", "phpstan.neon.dist"]).is_some() {
|
|
return true;
|
|
}
|
|
if let Some(composer_json) = find_nearest_config(cwd, files, &["composer.json"]) {
|
|
return composer_requires_package(&composer_json, "phpstan/phpstan");
|
|
}
|
|
false
|
|
}
|
|
|
|
fn has_psalm_config(cwd: &Path, files: &[PathBuf]) -> bool {
|
|
let config_candidates = [
|
|
"psalm.xml",
|
|
"psalm.xml.dist",
|
|
".psalm/config.xml",
|
|
".psalm/config.xml.dist",
|
|
];
|
|
if find_nearest_config(cwd, files, &config_candidates).is_some() {
|
|
return true;
|
|
}
|
|
if let Some(composer_json) = find_nearest_config(cwd, files, &["composer.json"]) {
|
|
return composer_requires_package(&composer_json, "vimeo/psalm");
|
|
}
|
|
false
|
|
}
|
|
|
|
fn has_go_module(cwd: &Path) -> bool { cwd.join("go.mod").exists() }
|
|
|
|
fn collect_rust_manifests(cwd: &Path, rust_files: &[PathBuf]) -> Vec<PathBuf> {
|
|
let mut manifests = BTreeSet::new();
|
|
for relative in rust_files {
|
|
if let Some(manifest) = find_manifest(cwd, relative) {
|
|
manifests.insert(manifest);
|
|
}
|
|
}
|
|
manifests.into_iter().collect()
|
|
}
|
|
|
|
fn find_manifest(cwd: &Path, relative: &Path) -> Option<PathBuf> {
|
|
let absolute = cwd.join(relative);
|
|
let mut current = absolute.parent()?;
|
|
loop {
|
|
let candidate = current.join("Cargo.toml");
|
|
if candidate.exists() {
|
|
return Some(candidate);
|
|
}
|
|
if current == cwd {
|
|
break;
|
|
}
|
|
current = current.parent()?;
|
|
}
|
|
None
|
|
}
|
|
|
|
struct WorkspaceOverlay {
|
|
backups: Vec<(PathBuf, Option<Vec<u8>>)>,
|
|
created_dirs: Vec<PathBuf>,
|
|
}
|
|
|
|
impl WorkspaceOverlay {
|
|
fn apply(action: &ApplyPatchAction) -> std::io::Result<Self> {
|
|
let mut overlay = WorkspaceOverlay { backups: Vec::new(), created_dirs: Vec::new() };
|
|
let mut seen: HashSet<PathBuf> = HashSet::new();
|
|
|
|
for (path, change) in action.changes() {
|
|
match change {
|
|
ApplyPatchFileChange::Add { content } => {
|
|
overlay.write_file(path, content, &mut seen)?;
|
|
}
|
|
ApplyPatchFileChange::Update { new_content, move_path, .. } => {
|
|
if let Some(dest) = move_path {
|
|
overlay.write_file(dest, new_content, &mut seen)?;
|
|
if dest != path {
|
|
overlay.remove_file(path, &mut seen)?;
|
|
}
|
|
} else {
|
|
overlay.write_file(path, new_content, &mut seen)?;
|
|
}
|
|
}
|
|
ApplyPatchFileChange::Delete { .. } => {
|
|
overlay.remove_file(path, &mut seen)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(overlay)
|
|
}
|
|
|
|
fn write_file(
|
|
&mut self,
|
|
path: &Path,
|
|
contents: &str,
|
|
seen: &mut HashSet<PathBuf>,
|
|
) -> std::io::Result<()> {
|
|
self.backup_if_needed(path, seen)?;
|
|
if let Some(parent) = path.parent() {
|
|
self.ensure_dir(parent)?;
|
|
}
|
|
let mut file = fs::File::create(path)?;
|
|
file.write_all(contents.as_bytes())?;
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_file(&mut self, path: &Path, seen: &mut HashSet<PathBuf>) -> std::io::Result<()> {
|
|
self.backup_if_needed(path, seen)?;
|
|
if let Err(err) = fs::remove_file(path) {
|
|
if err.kind() != std::io::ErrorKind::NotFound {
|
|
return Err(err);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn backup_if_needed(&mut self, path: &Path, seen: &mut HashSet<PathBuf>) -> std::io::Result<()> {
|
|
if !seen.insert(path.to_path_buf()) {
|
|
return Ok(());
|
|
}
|
|
let original = match fs::read(path) {
|
|
Ok(bytes) => Some(bytes),
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
|
Err(err) => return Err(err),
|
|
};
|
|
self.backups.push((path.to_path_buf(), original));
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_dir(&mut self, dir: &Path) -> std::io::Result<()> {
|
|
if dir.exists() {
|
|
return Ok(());
|
|
}
|
|
let mut to_create: Vec<PathBuf> = Vec::new();
|
|
let mut current = dir.to_path_buf();
|
|
while !current.exists() {
|
|
to_create.push(current.clone());
|
|
if let Some(parent) = current.parent() {
|
|
current = parent.to_path_buf();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
for path in to_create.iter().rev() {
|
|
fs::create_dir(path)?;
|
|
self.created_dirs.push(path.clone());
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for WorkspaceOverlay {
|
|
fn drop(&mut self) {
|
|
for (path, original) in self.backups.iter().rev() {
|
|
match original {
|
|
Some(bytes) => {
|
|
if let Some(parent) = path.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
}
|
|
let _ = fs::File::create(path).and_then(|mut file| file.write_all(bytes));
|
|
}
|
|
None => {
|
|
let _ = fs::remove_file(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
for dir in self.created_dirs.iter().rev() {
|
|
let _ = fs::remove_dir(dir);
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CommandCapture {
|
|
status: Option<ExitStatus>,
|
|
stdout: Vec<u8>,
|
|
stderr: Vec<u8>,
|
|
}
|