2026-04-12 13:11:23 +00:00
|
|
|
//! Acceptance gates — runs test suites and validation scripts in agent worktrees.
|
2026-05-13 15:57:24 +00:00
|
|
|
use serde::{Deserialize, Serialize};
|
2026-03-22 19:07:07 +00:00
|
|
|
use std::path::Path;
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
use wait_timeout::ChildExt;
|
|
|
|
|
|
2026-05-13 15:57:24 +00:00
|
|
|
/// Typed classification of a gate failure, produced at the gate execution boundary.
|
|
|
|
|
///
|
|
|
|
|
/// Downstream decision logic (e.g. `is_self_evident_fix`) matches on the variant
|
|
|
|
|
/// rather than scanning the raw output string for patterns.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub enum GateFailureKind {
|
|
|
|
|
/// `cargo fmt --check` or `rustfmt --check` detected formatting drift.
|
|
|
|
|
Fmt,
|
|
|
|
|
/// `cargo clippy` produced warnings or errors (promoted via `-D warnings`).
|
|
|
|
|
Lint,
|
|
|
|
|
/// Test suite (`script/test`, `cargo nextest`, `cargo test`) failed.
|
|
|
|
|
Test,
|
|
|
|
|
/// `source-map-check` gate found missing or incomplete doc comments.
|
|
|
|
|
SourceMapCheck,
|
|
|
|
|
/// Git content conflict detected during squash-rebase.
|
|
|
|
|
ContentConflict,
|
|
|
|
|
/// Build-level failure (duplicate module files E0761, compile error).
|
|
|
|
|
Build,
|
|
|
|
|
/// Unclassified gate failure.
|
|
|
|
|
Other,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GateFailureKind {
|
|
|
|
|
/// Classify a gate failure from its raw output at the gate execution boundary.
|
|
|
|
|
///
|
|
|
|
|
/// Called once when a gate fails to produce a typed kind. Downstream code
|
|
|
|
|
/// matches on the variant and must not call this on subsequent reads.
|
|
|
|
|
pub fn classify(output: &str) -> Self {
|
|
|
|
|
if output.contains("CONFLICT (content):") || output.contains("Merge conflict:") {
|
|
|
|
|
GateFailureKind::ContentConflict
|
|
|
|
|
} else if output.contains("Diff in ") || output.contains("would reformat") {
|
|
|
|
|
GateFailureKind::Fmt
|
|
|
|
|
} else if output.contains("missing-docs direction") {
|
|
|
|
|
GateFailureKind::SourceMapCheck
|
|
|
|
|
} else if output.contains("error[clippy::")
|
|
|
|
|
|| output.contains("warning[clippy::")
|
|
|
|
|
|| output.contains("missing_doc_comments")
|
|
|
|
|
{
|
|
|
|
|
GateFailureKind::Lint
|
2026-05-14 10:18:33 +01:00
|
|
|
} else if output.contains("error[E") {
|
|
|
|
|
// rustc compile errors (e.g. `error[E0063]: missing field`).
|
|
|
|
|
// When this appears in the post-squash gate run, it almost always
|
|
|
|
|
// signals cross-merge breakage — master gained a field/variant the
|
|
|
|
|
// feature branch's code does not match. Mergemaster handles the
|
|
|
|
|
// recovery in its ConflictDetected path.
|
|
|
|
|
GateFailureKind::Build
|
2026-05-13 15:57:24 +00:00
|
|
|
} else {
|
|
|
|
|
GateFailureKind::Test
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether this failure class is a self-evident fix that a short coder session
|
|
|
|
|
/// can resolve without human intervention (fmt drift, lint warnings, missing docs).
|
|
|
|
|
pub fn is_self_evident_fix(&self) -> bool {
|
|
|
|
|
matches!(
|
|
|
|
|
self,
|
|
|
|
|
GateFailureKind::Fmt | GateFailureKind::Lint | GateFailureKind::SourceMapCheck
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Outcome of running quality gates, produced at the gate execution boundary.
|
|
|
|
|
///
|
|
|
|
|
/// `failure_kind` drives routing decisions; `output` is retained for human-readable
|
|
|
|
|
/// display and injection into agent retry prompts only — it must not be used as a
|
|
|
|
|
/// decision source (story 986).
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct GateOutcome {
|
|
|
|
|
/// Whether all gates passed.
|
|
|
|
|
pub passed: bool,
|
|
|
|
|
/// Typed failure classification; `None` when `passed` is `true`.
|
|
|
|
|
pub failure_kind: Option<GateFailureKind>,
|
|
|
|
|
/// Human-readable combined gate output (display/prompt injection only).
|
|
|
|
|
pub output: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GateOutcome {
|
|
|
|
|
/// Passing outcome.
|
|
|
|
|
pub(crate) fn pass(output: String) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
passed: true,
|
|
|
|
|
failure_kind: None,
|
|
|
|
|
output,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Failing outcome — classifies `failure_kind` from the output at construction.
|
|
|
|
|
pub(crate) fn fail(output: String) -> Self {
|
|
|
|
|
let failure_kind = Some(GateFailureKind::classify(&output));
|
|
|
|
|
Self {
|
|
|
|
|
passed: false,
|
|
|
|
|
failure_kind,
|
|
|
|
|
output,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Failing outcome for a pre-classified build error (e.g. duplicate module files).
|
|
|
|
|
pub(crate) fn build_error(output: String) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
passed: false,
|
|
|
|
|
failure_kind: Some(GateFailureKind::Build),
|
|
|
|
|
output,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// Maximum time any single test command is allowed to run before being killed.
|
2026-04-07 15:47:44 +00:00
|
|
|
const TEST_TIMEOUT: Duration = Duration::from_secs(1200); // 20 minutes
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
/// Detect whether the base branch in a worktree is `master` or `main`.
|
|
|
|
|
/// Falls back to `"master"` if neither is found.
|
|
|
|
|
pub(crate) fn detect_worktree_base_branch(wt_path: &Path) -> String {
|
|
|
|
|
for branch in &["master", "main"] {
|
|
|
|
|
let ok = Command::new("git")
|
|
|
|
|
.args(["rev-parse", "--verify", branch])
|
|
|
|
|
.current_dir(wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.map(|o| o.status.success())
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
if ok {
|
|
|
|
|
return branch.to_string();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"master".to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return `true` if the git worktree at `wt_path` has commits on its current
|
|
|
|
|
/// branch that are not present on the base branch (`master` or `main`).
|
|
|
|
|
///
|
|
|
|
|
/// Used during server startup reconciliation to detect stories whose agent work
|
|
|
|
|
/// was committed while the server was offline.
|
|
|
|
|
pub(crate) fn worktree_has_committed_work(wt_path: &Path) -> bool {
|
|
|
|
|
let base_branch = detect_worktree_base_branch(wt_path);
|
|
|
|
|
let output = Command::new("git")
|
|
|
|
|
.args(["log", &format!("{base_branch}..HEAD"), "--oneline"])
|
|
|
|
|
.current_dir(wt_path)
|
|
|
|
|
.output();
|
|
|
|
|
match output {
|
2026-04-13 14:07:08 +00:00
|
|
|
Ok(out) if out.status.success() => !String::from_utf8_lossy(&out.stdout).trim().is_empty(),
|
2026-03-22 19:07:07 +00:00
|
|
|
_ => false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 10:50:40 +00:00
|
|
|
/// Run `cargo check` in the given worktree directory to verify committed code compiles.
|
|
|
|
|
///
|
2026-04-26 16:42:10 +00:00
|
|
|
/// Stashes any dirty (uncommitted) files first so that only the committed state
|
|
|
|
|
/// is evaluated, then restores them afterward. Uncommitted work in worktrees is
|
|
|
|
|
/// never junk — it may be the next agent session's starting point (bug 651).
|
|
|
|
|
///
|
2026-04-27 11:33:36 +00:00
|
|
|
/// No longer called from main pipeline code (bug 668 replaced cargo-check with
|
|
|
|
|
/// run_tests evidence), but retained for the bug-651 stash/restore regression test.
|
|
|
|
|
#[cfg(test)]
|
2026-04-26 10:50:40 +00:00
|
|
|
pub(crate) fn cargo_check_in_worktree(wt_path: &Path) -> bool {
|
2026-04-26 16:42:10 +00:00
|
|
|
// Stash uncommitted changes (including untracked files) so cargo check
|
|
|
|
|
// evaluates only committed code. We restore them afterward.
|
|
|
|
|
let stashed = Command::new("git")
|
|
|
|
|
.args([
|
|
|
|
|
"stash",
|
|
|
|
|
"push",
|
|
|
|
|
"--include-untracked",
|
|
|
|
|
"-m",
|
|
|
|
|
"cargo-check-temp",
|
|
|
|
|
])
|
2026-04-26 10:50:40 +00:00
|
|
|
.current_dir(wt_path)
|
2026-04-26 16:42:10 +00:00
|
|
|
.output()
|
|
|
|
|
.map(|o| {
|
|
|
|
|
o.status.success()
|
|
|
|
|
&& !String::from_utf8_lossy(&o.stdout).contains("No local changes to save")
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(false);
|
2026-04-26 10:50:40 +00:00
|
|
|
|
2026-04-26 16:42:10 +00:00
|
|
|
let result = Command::new("cargo")
|
2026-04-26 10:50:40 +00:00
|
|
|
.args(["check"])
|
|
|
|
|
.current_dir(wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.map(|o| o.status.success())
|
2026-04-26 16:42:10 +00:00
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
// Restore stashed uncommitted changes.
|
|
|
|
|
if stashed {
|
|
|
|
|
let _ = Command::new("git")
|
|
|
|
|
.args(["stash", "pop"])
|
|
|
|
|
.current_dir(wt_path)
|
|
|
|
|
.output();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result
|
2026-04-26 10:50:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// Check whether the given directory has any uncommitted git changes.
|
|
|
|
|
/// Returns `Err` with a descriptive message if there are any.
|
|
|
|
|
pub(crate) fn check_uncommitted_changes(path: &Path) -> Result<(), String> {
|
|
|
|
|
let output = Command::new("git")
|
|
|
|
|
.args(["status", "--porcelain"])
|
|
|
|
|
.current_dir(path)
|
|
|
|
|
.output()
|
|
|
|
|
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
|
|
|
|
|
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
|
if !stdout.trim().is_empty() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"Worktree has uncommitted changes. Please commit all work before \
|
|
|
|
|
the agent exits:\n{stdout}"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run the project's test suite.
|
|
|
|
|
///
|
|
|
|
|
/// Uses `script/test` if present, treating it as the canonical single test entry point.
|
|
|
|
|
/// Falls back to `cargo nextest run` / `cargo test` when `script/test` is absent.
|
|
|
|
|
/// Returns `(tests_passed, output)`.
|
|
|
|
|
pub(crate) fn run_project_tests(path: &Path) -> Result<(bool, String), String> {
|
|
|
|
|
let script_test = path.join("script").join("test");
|
|
|
|
|
if script_test.exists() {
|
|
|
|
|
let mut output = String::from("=== script/test ===\n");
|
|
|
|
|
let (success, out) = run_command_with_timeout(&script_test, &[], path)?;
|
|
|
|
|
output.push_str(&out);
|
|
|
|
|
output.push('\n');
|
|
|
|
|
return Ok((success, output));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: cargo nextest run / cargo test
|
|
|
|
|
let mut output = String::from("=== tests ===\n");
|
|
|
|
|
let (success, test_out) = match run_command_with_timeout("cargo", &["nextest", "run"], path) {
|
|
|
|
|
Ok(result) => result,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
// nextest not available — fall back to cargo test
|
|
|
|
|
run_command_with_timeout("cargo", &["test"], path)
|
|
|
|
|
.map_err(|e| format!("Failed to run cargo test: {e}"))?
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
output.push_str(&test_out);
|
|
|
|
|
output.push('\n');
|
|
|
|
|
Ok((success, output))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run a command with a timeout. Returns `(success, combined_output)`.
|
|
|
|
|
/// Kills the child process if it exceeds `TEST_TIMEOUT`.
|
|
|
|
|
///
|
|
|
|
|
/// Stdout and stderr are drained in background threads to avoid a pipe-buffer
|
|
|
|
|
/// deadlock: if the child fills the 64 KB OS pipe buffer while the parent
|
|
|
|
|
/// blocks on `waitpid`, neither side can make progress.
|
|
|
|
|
fn run_command_with_timeout(
|
|
|
|
|
program: impl AsRef<std::ffi::OsStr>,
|
|
|
|
|
args: &[&str],
|
|
|
|
|
dir: &Path,
|
|
|
|
|
) -> Result<(bool, String), String> {
|
2026-03-23 18:43:14 +00:00
|
|
|
// On Linux, execve can return ETXTBSY (26) briefly after a file is written
|
|
|
|
|
// before the kernel releases its "write open" state. Retry once after a
|
|
|
|
|
// short pause to handle this race condition.
|
|
|
|
|
let mut last_err = None;
|
|
|
|
|
let mut cmd = Command::new(&program);
|
|
|
|
|
cmd.args(args)
|
2026-03-22 19:07:07 +00:00
|
|
|
.current_dir(dir)
|
|
|
|
|
.stdout(std::process::Stdio::piped())
|
2026-03-23 18:43:14 +00:00
|
|
|
.stderr(std::process::Stdio::piped());
|
|
|
|
|
let mut child = loop {
|
|
|
|
|
match cmd.spawn() {
|
|
|
|
|
Ok(c) => break c,
|
|
|
|
|
Err(e) if e.raw_os_error() == Some(26) => {
|
|
|
|
|
// ETXTBSY — wait briefly and retry once
|
|
|
|
|
if last_err.is_some() {
|
|
|
|
|
return Err(format!("Failed to spawn command: {e}"));
|
|
|
|
|
}
|
|
|
|
|
last_err = Some(e);
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
|
}
|
|
|
|
|
Err(e) => return Err(format!("Failed to spawn command: {e}")),
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
// Drain stdout/stderr in background threads so the pipe buffers never fill.
|
|
|
|
|
let stdout_handle = child.stdout.take().map(|r| {
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let mut s = String::new();
|
|
|
|
|
let mut r = r;
|
|
|
|
|
std::io::Read::read_to_string(&mut r, &mut s).ok();
|
|
|
|
|
s
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
let stderr_handle = child.stderr.take().map(|r| {
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let mut s = String::new();
|
|
|
|
|
let mut r = r;
|
|
|
|
|
std::io::Read::read_to_string(&mut r, &mut s).ok();
|
|
|
|
|
s
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
match child.wait_timeout(TEST_TIMEOUT) {
|
|
|
|
|
Ok(Some(status)) => {
|
|
|
|
|
let stdout = stdout_handle
|
|
|
|
|
.and_then(|h| h.join().ok())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let stderr = stderr_handle
|
|
|
|
|
.and_then(|h| h.join().ok())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
Ok((status.success(), format!("{stdout}{stderr}")))
|
|
|
|
|
}
|
|
|
|
|
Ok(None) => {
|
|
|
|
|
// Timed out — kill the child.
|
|
|
|
|
let _ = child.kill();
|
|
|
|
|
let _ = child.wait();
|
|
|
|
|
Err(format!(
|
|
|
|
|
"Command timed out after {} seconds",
|
|
|
|
|
TEST_TIMEOUT.as_secs()
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
Err(e) => Err(format!("Failed to wait for command: {e}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run `cargo clippy` and the project test suite (via `script/test` if present,
|
|
|
|
|
/// otherwise `cargo nextest run` / `cargo test`) in the given directory.
|
2026-05-13 15:57:24 +00:00
|
|
|
/// Returns a [`GateOutcome`] with a typed failure classification.
|
|
|
|
|
pub(crate) fn run_acceptance_gates(path: &Path) -> Result<GateOutcome, String> {
|
2026-04-29 08:38:00 +00:00
|
|
|
// Pre-flight: detect duplicate module files (E0761) before running the
|
|
|
|
|
// full test suite so the failure message is immediately actionable.
|
|
|
|
|
let duplicates = find_duplicate_module_files(path);
|
|
|
|
|
if !duplicates.is_empty() {
|
|
|
|
|
let mut msg = String::from(
|
|
|
|
|
"ERROR [E0761]: duplicate module files detected — cargo will fail to compile.\n\
|
|
|
|
|
Fix: git rm the flat .rs file in the same commit that introduces the mod.rs.\n",
|
|
|
|
|
);
|
|
|
|
|
for (flat, mod_path) in &duplicates {
|
|
|
|
|
msg.push_str(&format!(
|
|
|
|
|
" {} (conflicts with {})\n",
|
|
|
|
|
flat.display(),
|
|
|
|
|
mod_path.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-05-13 15:57:24 +00:00
|
|
|
return Ok(GateOutcome::build_error(msg));
|
2026-04-29 08:38:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 10:48:06 +00:00
|
|
|
// Run script/test (or fallback to cargo test). Project-specific linting
|
|
|
|
|
// and test commands belong in script/test.
|
2026-03-22 19:07:07 +00:00
|
|
|
let (test_success, test_out) = run_project_tests(path)?;
|
2026-04-29 10:48:06 +00:00
|
|
|
if !test_success {
|
2026-05-13 15:57:24 +00:00
|
|
|
return Ok(GateOutcome::fail(test_out));
|
2026-04-29 10:48:06 +00:00
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-05-13 15:57:24 +00:00
|
|
|
Ok(GateOutcome::pass(test_out))
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 08:38:00 +00:00
|
|
|
/// Scan `root` recursively for Rust source files where both `path/X.rs` and
|
|
|
|
|
/// `path/X/mod.rs` exist — a condition that produces a `duplicate module file`
|
|
|
|
|
/// cargo error (E0761).
|
|
|
|
|
///
|
|
|
|
|
/// Returns a sorted list of `(flat_path, mod_path)` pairs in conflict.
|
|
|
|
|
/// Directories named `target` or starting with `.` are skipped.
|
|
|
|
|
pub(crate) fn find_duplicate_module_files(
|
|
|
|
|
root: &Path,
|
|
|
|
|
) -> Vec<(std::path::PathBuf, std::path::PathBuf)> {
|
|
|
|
|
let mut duplicates = Vec::new();
|
|
|
|
|
find_duplicates_recursive(root, &mut duplicates);
|
|
|
|
|
duplicates.sort();
|
|
|
|
|
duplicates
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn find_duplicates_recursive(dir: &Path, out: &mut Vec<(std::path::PathBuf, std::path::PathBuf)>) {
|
|
|
|
|
let entries = match std::fs::read_dir(dir) {
|
|
|
|
|
Ok(e) => e,
|
|
|
|
|
Err(_) => return,
|
|
|
|
|
};
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if !path.is_dir() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let name = match path.file_name().and_then(|n| n.to_str()) {
|
|
|
|
|
Some(n) => n.to_owned(),
|
|
|
|
|
None => continue,
|
|
|
|
|
};
|
|
|
|
|
if name == "target" || name.starts_with('.') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let flat = dir.join(format!("{name}.rs"));
|
|
|
|
|
let mod_rs = path.join("mod.rs");
|
|
|
|
|
if flat.exists() && mod_rs.exists() {
|
|
|
|
|
out.push((flat, mod_rs));
|
|
|
|
|
}
|
|
|
|
|
find_duplicates_recursive(&path, out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// Run `script/test_coverage` in the given directory if the script exists.
|
|
|
|
|
///
|
|
|
|
|
/// Used as a QA gate before advancing a story from `3_qa/` to `4_merge/`.
|
|
|
|
|
/// Returns `(passed, output)`. If the script does not exist, returns `(true, …)`.
|
|
|
|
|
pub(crate) fn run_coverage_gate(path: &Path) -> Result<(bool, String), String> {
|
|
|
|
|
let script = path.join("script").join("test_coverage");
|
|
|
|
|
if !script.exists() {
|
|
|
|
|
return Ok((
|
|
|
|
|
true,
|
|
|
|
|
"script/test_coverage not found; coverage gate skipped.\n".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut output = String::from("=== script/test_coverage ===\n");
|
2026-04-12 12:58:51 +00:00
|
|
|
let result = match Command::new(&script).current_dir(path).output() {
|
|
|
|
|
Ok(r) => r,
|
|
|
|
|
Err(e) if e.raw_os_error() == Some(26) => {
|
|
|
|
|
// ETXTBSY — retry once after a brief pause.
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
|
Command::new(&script)
|
|
|
|
|
.current_dir(path)
|
|
|
|
|
.output()
|
|
|
|
|
.map_err(|e| format!("Failed to run script/test_coverage: {e}"))?
|
|
|
|
|
}
|
|
|
|
|
Err(e) => return Err(format!("Failed to run script/test_coverage: {e}")),
|
|
|
|
|
};
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
let combined = format!(
|
|
|
|
|
"{}{}",
|
|
|
|
|
String::from_utf8_lossy(&result.stdout),
|
|
|
|
|
String::from_utf8_lossy(&result.stderr)
|
|
|
|
|
);
|
|
|
|
|
output.push_str(&combined);
|
|
|
|
|
output.push('\n');
|
|
|
|
|
|
|
|
|
|
Ok((result.status.success(), output))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn init_git_repo(repo: &std::path::Path) {
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["init"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["config", "user.email", "test@test.com"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["config", "user.name", "Test"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["commit", "--allow-empty", "-m", "init"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 08:38:00 +00:00
|
|
|
// ── find_duplicate_module_files tests ────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn find_duplicate_module_files_returns_empty_when_no_duplicates() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let src = tmp.path().join("src");
|
|
|
|
|
fs::create_dir_all(&src).unwrap();
|
|
|
|
|
// Only X/mod.rs, no X.rs
|
|
|
|
|
let sub = src.join("util");
|
|
|
|
|
fs::create_dir_all(&sub).unwrap();
|
|
|
|
|
fs::write(sub.join("mod.rs"), "").unwrap();
|
|
|
|
|
let result = find_duplicate_module_files(tmp.path());
|
|
|
|
|
assert!(result.is_empty(), "no duplicates expected: {result:?}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn find_duplicate_module_files_detects_flat_and_mod_rs() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let src = tmp.path().join("src");
|
|
|
|
|
fs::create_dir_all(&src).unwrap();
|
|
|
|
|
// Create both src/config.rs and src/config/mod.rs
|
|
|
|
|
fs::write(src.join("config.rs"), "").unwrap();
|
|
|
|
|
let config_dir = src.join("config");
|
|
|
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
|
|
|
fs::write(config_dir.join("mod.rs"), "").unwrap();
|
|
|
|
|
let result = find_duplicate_module_files(tmp.path());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result.len(),
|
|
|
|
|
1,
|
|
|
|
|
"expected exactly one duplicate: {result:?}"
|
|
|
|
|
);
|
|
|
|
|
let (flat, mod_path) = &result[0];
|
|
|
|
|
assert!(
|
|
|
|
|
flat.ends_with("src/config.rs"),
|
|
|
|
|
"flat path should be config.rs, got {flat:?}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
mod_path.ends_with("src/config/mod.rs"),
|
|
|
|
|
"mod path should be config/mod.rs, got {mod_path:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn find_duplicate_module_files_skips_target_directory() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
// Duplicate inside target/ should be ignored
|
|
|
|
|
let target = tmp.path().join("target").join("debug").join("foo");
|
|
|
|
|
fs::create_dir_all(&target).unwrap();
|
|
|
|
|
fs::write(target.join("mod.rs"), "").unwrap();
|
|
|
|
|
fs::write(tmp.path().join("target").join("debug").join("foo.rs"), "").unwrap();
|
|
|
|
|
let result = find_duplicate_module_files(tmp.path());
|
|
|
|
|
assert!(
|
|
|
|
|
result.is_empty(),
|
|
|
|
|
"duplicates inside target/ should be ignored: {result:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn find_duplicate_module_files_reports_both_paths_in_message() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let src = tmp.path().join("src");
|
|
|
|
|
fs::create_dir_all(&src).unwrap();
|
|
|
|
|
fs::write(src.join("handlers.rs"), "").unwrap();
|
|
|
|
|
let handlers_dir = src.join("handlers");
|
|
|
|
|
fs::create_dir_all(&handlers_dir).unwrap();
|
|
|
|
|
fs::write(handlers_dir.join("mod.rs"), "").unwrap();
|
|
|
|
|
let result = find_duplicate_module_files(tmp.path());
|
|
|
|
|
assert_eq!(result.len(), 1);
|
|
|
|
|
let (flat, mod_path) = &result[0];
|
|
|
|
|
// Both file names must appear in the paths so a caller can surface them
|
|
|
|
|
assert!(flat.to_string_lossy().contains("handlers.rs"));
|
|
|
|
|
assert!(mod_path.to_string_lossy().contains("handlers"));
|
|
|
|
|
assert!(mod_path.to_string_lossy().contains("mod.rs"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
// ── run_project_tests tests ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
#[test]
|
|
|
|
|
fn run_project_tests_uses_script_test_when_present_and_passes() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path();
|
|
|
|
|
let script_dir = path.join("script");
|
|
|
|
|
fs::create_dir_all(&script_dir).unwrap();
|
|
|
|
|
let script_test = script_dir.join("test");
|
2026-04-13 14:07:08 +00:00
|
|
|
fs::write(
|
|
|
|
|
&script_test,
|
|
|
|
|
"#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-03-22 19:07:07 +00:00
|
|
|
let mut perms = fs::metadata(&script_test).unwrap().permissions();
|
|
|
|
|
perms.set_mode(0o755);
|
|
|
|
|
fs::set_permissions(&script_test, perms).unwrap();
|
|
|
|
|
|
|
|
|
|
let (passed, output) = run_project_tests(path).unwrap();
|
|
|
|
|
assert!(passed, "script/test exiting 0 should pass");
|
2026-04-13 14:07:08 +00:00
|
|
|
assert!(
|
|
|
|
|
output.contains("script/test"),
|
|
|
|
|
"output should mention script/test"
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
#[test]
|
|
|
|
|
fn run_project_tests_reports_failure_when_script_test_exits_nonzero() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path();
|
|
|
|
|
let script_dir = path.join("script");
|
|
|
|
|
fs::create_dir_all(&script_dir).unwrap();
|
|
|
|
|
let script_test = script_dir.join("test");
|
|
|
|
|
fs::write(&script_test, "#!/usr/bin/env bash\nexit 1\n").unwrap();
|
|
|
|
|
let mut perms = fs::metadata(&script_test).unwrap().permissions();
|
|
|
|
|
perms.set_mode(0o755);
|
|
|
|
|
fs::set_permissions(&script_test, perms).unwrap();
|
|
|
|
|
|
|
|
|
|
let (passed, output) = run_project_tests(path).unwrap();
|
|
|
|
|
assert!(!passed, "script/test exiting 1 should fail");
|
2026-04-13 14:07:08 +00:00
|
|
|
assert!(
|
|
|
|
|
output.contains("script/test"),
|
|
|
|
|
"output should mention script/test"
|
|
|
|
|
);
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── run_coverage_gate tests ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
#[test]
|
|
|
|
|
fn coverage_gate_passes_when_script_absent() {
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let (passed, output) = run_coverage_gate(tmp.path()).unwrap();
|
|
|
|
|
assert!(passed, "coverage gate should pass when script is absent");
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("not found"),
|
|
|
|
|
"output should mention script not found"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
#[test]
|
|
|
|
|
fn coverage_gate_passes_when_script_exits_zero() {
|
|
|
|
|
use std::fs;
|
2026-04-12 12:37:05 +00:00
|
|
|
use std::io::Write;
|
2026-03-22 19:07:07 +00:00
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path();
|
|
|
|
|
let script_dir = path.join("script");
|
|
|
|
|
fs::create_dir_all(&script_dir).unwrap();
|
|
|
|
|
let script = script_dir.join("test_coverage");
|
2026-04-12 12:37:05 +00:00
|
|
|
{
|
|
|
|
|
let mut f = fs::File::create(&script).unwrap();
|
|
|
|
|
f.write_all(b"#!/usr/bin/env bash\necho 'Rust line coverage: 85%'\necho 'PASS: Coverage 85% meets threshold 0%'\nexit 0\n").unwrap();
|
|
|
|
|
f.sync_all().unwrap();
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
let mut perms = fs::metadata(&script).unwrap().permissions();
|
|
|
|
|
perms.set_mode(0o755);
|
|
|
|
|
fs::set_permissions(&script, perms).unwrap();
|
|
|
|
|
|
|
|
|
|
let (passed, output) = run_coverage_gate(path).unwrap();
|
|
|
|
|
assert!(passed, "coverage gate should pass when script exits 0");
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("script/test_coverage"),
|
|
|
|
|
"output should mention script/test_coverage"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
#[test]
|
|
|
|
|
fn coverage_gate_fails_when_script_exits_nonzero() {
|
|
|
|
|
use std::fs;
|
2026-04-12 12:37:05 +00:00
|
|
|
use std::io::Write;
|
2026-03-22 19:07:07 +00:00
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = tmp.path();
|
|
|
|
|
let script_dir = path.join("script");
|
|
|
|
|
fs::create_dir_all(&script_dir).unwrap();
|
|
|
|
|
let script = script_dir.join("test_coverage");
|
2026-04-12 12:37:05 +00:00
|
|
|
{
|
|
|
|
|
let mut f = fs::File::create(&script).unwrap();
|
2026-04-13 14:07:08 +00:00
|
|
|
f.write_all(
|
|
|
|
|
b"#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-04-12 12:37:05 +00:00
|
|
|
f.sync_all().unwrap();
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
let mut perms = fs::metadata(&script).unwrap().permissions();
|
|
|
|
|
perms.set_mode(0o755);
|
|
|
|
|
fs::set_permissions(&script, perms).unwrap();
|
|
|
|
|
|
|
|
|
|
let (passed, output) = run_coverage_gate(path).unwrap();
|
|
|
|
|
assert!(!passed, "coverage gate should fail when script exits 1");
|
|
|
|
|
assert!(
|
|
|
|
|
output.contains("script/test_coverage"),
|
|
|
|
|
"output should mention script/test_coverage"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── worktree_has_committed_work tests ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn worktree_has_committed_work_false_on_fresh_repo() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let repo = tmp.path();
|
|
|
|
|
// init_git_repo creates the initial commit on the default branch.
|
|
|
|
|
// HEAD IS the base branch — no commits ahead.
|
|
|
|
|
init_git_repo(repo);
|
|
|
|
|
assert!(!worktree_has_committed_work(repo));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn worktree_has_committed_work_true_after_commit_on_feature_branch() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let project_root = tmp.path().join("project");
|
|
|
|
|
fs::create_dir_all(&project_root).unwrap();
|
|
|
|
|
init_git_repo(&project_root);
|
|
|
|
|
|
|
|
|
|
// Create a git worktree on a feature branch.
|
|
|
|
|
let wt_path = tmp.path().join("wt");
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args([
|
|
|
|
|
"worktree",
|
|
|
|
|
"add",
|
|
|
|
|
&wt_path.to_string_lossy(),
|
|
|
|
|
"-b",
|
|
|
|
|
"feature/story-99_test",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&project_root)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// No commits on the feature branch yet — same as base branch.
|
|
|
|
|
assert!(!worktree_has_committed_work(&wt_path));
|
|
|
|
|
|
|
|
|
|
// Add a commit to the feature branch in the worktree.
|
|
|
|
|
fs::write(wt_path.join("work.txt"), "done").unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args([
|
|
|
|
|
"-c",
|
|
|
|
|
"user.email=test@test.com",
|
|
|
|
|
"-c",
|
|
|
|
|
"user.name=Test",
|
|
|
|
|
"commit",
|
|
|
|
|
"-m",
|
|
|
|
|
"coder: implement story",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Now the feature branch is ahead of the base branch.
|
|
|
|
|
assert!(worktree_has_committed_work(&wt_path));
|
|
|
|
|
}
|
2026-04-26 10:50:40 +00:00
|
|
|
|
|
|
|
|
// ── cargo_check_in_worktree tests ────────────────────────────────────────
|
|
|
|
|
|
2026-04-26 16:42:10 +00:00
|
|
|
/// Bug 645 + 651: cargo_check_in_worktree stashes dirty files before
|
|
|
|
|
/// checking committed code and restores them afterward.
|
2026-04-26 10:50:40 +00:00
|
|
|
#[test]
|
2026-04-26 16:42:10 +00:00
|
|
|
fn cargo_check_in_worktree_stashes_and_restores_dirty_files() {
|
2026-04-26 10:50:40 +00:00
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let project_root = tmp.path().join("project");
|
|
|
|
|
fs::create_dir_all(&project_root).unwrap();
|
|
|
|
|
init_git_repo(&project_root);
|
|
|
|
|
|
|
|
|
|
// Create a minimal Cargo project so cargo check works.
|
|
|
|
|
fs::write(
|
|
|
|
|
project_root.join("Cargo.toml"),
|
|
|
|
|
"[package]\nname = \"test_proj\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::create_dir_all(project_root.join("src")).unwrap();
|
|
|
|
|
fs::write(project_root.join("src/lib.rs"), "// empty lib\n").unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
.current_dir(&project_root)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args([
|
|
|
|
|
"-c",
|
|
|
|
|
"user.email=test@test.com",
|
|
|
|
|
"-c",
|
|
|
|
|
"user.name=Test",
|
|
|
|
|
"commit",
|
|
|
|
|
"-m",
|
|
|
|
|
"add cargo project",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&project_root)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Create a worktree on a feature branch.
|
|
|
|
|
let wt_path = tmp.path().join("wt");
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args([
|
|
|
|
|
"worktree",
|
|
|
|
|
"add",
|
|
|
|
|
&wt_path.to_string_lossy(),
|
|
|
|
|
"-b",
|
|
|
|
|
"feature/story-645_test",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&project_root)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Commit valid code.
|
|
|
|
|
fs::write(wt_path.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args([
|
|
|
|
|
"-c",
|
|
|
|
|
"user.email=test@test.com",
|
|
|
|
|
"-c",
|
|
|
|
|
"user.name=Test",
|
|
|
|
|
"commit",
|
|
|
|
|
"-m",
|
|
|
|
|
"add hello fn",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Now simulate a crash leaving dirty files (broken syntax).
|
|
|
|
|
fs::write(wt_path.join("src/lib.rs"), "THIS IS BROKEN SYNTAX!!!\n").unwrap();
|
2026-04-26 16:42:10 +00:00
|
|
|
// Also add an untracked file.
|
|
|
|
|
fs::write(wt_path.join("crash_residue.txt"), "untracked junk").unwrap();
|
2026-04-26 10:50:40 +00:00
|
|
|
|
2026-04-26 16:42:10 +00:00
|
|
|
// cargo_check_in_worktree should stash dirty files, check committed code, and restore.
|
2026-04-26 10:50:40 +00:00
|
|
|
assert!(
|
|
|
|
|
cargo_check_in_worktree(&wt_path),
|
2026-04-26 16:42:10 +00:00
|
|
|
"cargo check should pass on committed code after stashing dirty files"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Bug 651: dirty files must be restored after cargo check.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read_to_string(wt_path.join("src/lib.rs")).unwrap(),
|
|
|
|
|
"THIS IS BROKEN SYNTAX!!!\n",
|
|
|
|
|
"modified tracked file should be restored after cargo check"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
wt_path.join("crash_residue.txt").exists(),
|
|
|
|
|
"untracked file should be restored after cargo check"
|
2026-04-26 10:50:40 +00:00
|
|
|
);
|
|
|
|
|
}
|
2026-05-13 15:57:24 +00:00
|
|
|
|
|
|
|
|
// ── GateFailureKind::classify ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_fmt_from_diff_in() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify("Diff in server/src/lib.rs\n--- original\n+++ reformatted"),
|
|
|
|
|
GateFailureKind::Fmt
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_fmt_from_would_reformat() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify(
|
|
|
|
|
"Checking server/src/lib.rs\nwould reformat server/src/lib.rs"
|
|
|
|
|
),
|
|
|
|
|
GateFailureKind::Fmt
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_lint_from_clippy_error() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify("error[clippy::unused_variable]: unused variable `x`"),
|
|
|
|
|
GateFailureKind::Lint
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_lint_from_clippy_warning() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify("warning[clippy::needless_return]: unneeded return"),
|
|
|
|
|
GateFailureKind::Lint
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_lint_from_missing_doc_comments() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify(
|
|
|
|
|
"error: missing_doc_comments: public item lacks documentation"
|
|
|
|
|
),
|
|
|
|
|
GateFailureKind::Lint
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_source_map_check_from_missing_docs_direction() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify("missing-docs direction: server/src/foo.rs:42 pub fn bar"),
|
|
|
|
|
GateFailureKind::SourceMapCheck
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_content_conflict() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify("CONFLICT (content): Merge conflict in server/src/lib.rs"),
|
|
|
|
|
GateFailureKind::ContentConflict
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 10:18:33 +01:00
|
|
|
#[test]
|
|
|
|
|
fn classify_build_from_rustc_compile_error() {
|
|
|
|
|
// Post-squash compile errors (typical when master drifts under a feature
|
|
|
|
|
// branch — e.g. story 1018 hit `error[E0063]: missing field` after
|
|
|
|
|
// master gained a Stage::Coding field the feature branch did not set).
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify(
|
|
|
|
|
"error[E0063]: missing field `plan` in initializer of `Stage`\n --> server/src/foo.rs:166:20"
|
|
|
|
|
),
|
|
|
|
|
GateFailureKind::Build
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_build_does_not_misfire_on_clippy_error() {
|
|
|
|
|
// Clippy errors look like `error[clippy::name]` and must remain Lint,
|
|
|
|
|
// not Build, because the `error[E` prefix would otherwise overlap.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify("error[clippy::unused_variable]: unused variable `x`"),
|
|
|
|
|
GateFailureKind::Lint
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 15:57:24 +00:00
|
|
|
#[test]
|
|
|
|
|
fn classify_test_failure_for_unrecognised_output() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
GateFailureKind::classify("test result: FAILED. 3 passed; 1 failed"),
|
|
|
|
|
GateFailureKind::Test
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── GateFailureKind::is_self_evident_fix ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn fmt_is_self_evident_fix() {
|
|
|
|
|
assert!(GateFailureKind::Fmt.is_self_evident_fix());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn lint_is_self_evident_fix() {
|
|
|
|
|
assert!(GateFailureKind::Lint.is_self_evident_fix());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn source_map_check_is_self_evident_fix() {
|
|
|
|
|
assert!(GateFailureKind::SourceMapCheck.is_self_evident_fix());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_failure_is_not_self_evident_fix() {
|
|
|
|
|
assert!(!GateFailureKind::Test.is_self_evident_fix());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn content_conflict_is_not_self_evident_fix() {
|
|
|
|
|
assert!(!GateFailureKind::ContentConflict.is_self_evident_fix());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn build_error_is_not_self_evident_fix() {
|
|
|
|
|
assert!(!GateFailureKind::Build.is_self_evident_fix());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn other_is_not_self_evident_fix() {
|
|
|
|
|
assert!(!GateFailureKind::Other.is_self_evident_fix());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── GateOutcome constructors ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn gate_outcome_pass_has_no_failure_kind() {
|
|
|
|
|
let outcome = GateOutcome::pass("all tests passed".to_string());
|
|
|
|
|
assert!(outcome.passed);
|
|
|
|
|
assert!(outcome.failure_kind.is_none());
|
|
|
|
|
assert_eq!(outcome.output, "all tests passed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn gate_outcome_fail_classifies_kind() {
|
|
|
|
|
let outcome = GateOutcome::fail("Diff in server/src/lib.rs".to_string());
|
|
|
|
|
assert!(!outcome.passed);
|
|
|
|
|
assert_eq!(outcome.failure_kind, Some(GateFailureKind::Fmt));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn gate_outcome_build_error_sets_build_kind() {
|
|
|
|
|
let outcome = GateOutcome::build_error("ERROR [E0761]: duplicate module files".to_string());
|
|
|
|
|
assert!(!outcome.passed);
|
|
|
|
|
assert_eq!(outcome.failure_kind, Some(GateFailureKind::Build));
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|