//! Acceptance gates — runs test suites and validation scripts in agent worktrees. use serde::{Deserialize, Serialize}; use std::path::Path; use std::process::Command; use std::time::Duration; use wait_timeout::ChildExt; /// 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 { // Strip `test ... ok` lines before checking lint-trigger keywords so // a passing test whose name contains e.g. `missing_doc_comments` or `clippy::` // does not produce a false-positive Lint classification (story 1101). let stripped_for_lint: String = output .lines() .filter(|l| { let t = l.trim(); !(t.starts_with("test ") && t.ends_with("... ok")) }) .collect::>() .join("\n"); let is_lint = stripped_for_lint.contains("error[clippy::") || stripped_for_lint.contains("warning[clippy::") || stripped_for_lint.contains("missing_doc_comments"); 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 is_lint { GateFailureKind::Lint } 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 } 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, /// 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, } } } /// Maximum time any single test command is allowed to run before being killed. const TEST_TIMEOUT: Duration = Duration::from_secs(1200); // 20 minutes /// 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 { Ok(out) if out.status.success() => !String::from_utf8_lossy(&out.stdout).trim().is_empty(), _ => false, } } /// Run `cargo check` in the given worktree directory to verify committed code compiles. /// /// 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). /// /// 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)] pub(crate) fn cargo_check_in_worktree(wt_path: &Path) -> bool { // 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", ]) .current_dir(wt_path) .output() .map(|o| { o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("No local changes to save") }) .unwrap_or(false); let result = Command::new("cargo") .args(["check"]) .current_dir(wt_path) .output() .map(|o| o.status.success()) .unwrap_or(false); // Restore stashed uncommitted changes. if stashed { let _ = Command::new("git") .args(["stash", "pop"]) .current_dir(wt_path) .output(); } result } /// 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, args: &[&str], dir: &Path, ) -> Result<(bool, String), String> { // 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) .current_dir(dir) .stdout(std::process::Stdio::piped()) .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}")), } }; // 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. /// Returns a [`GateOutcome`] with a typed failure classification. pub(crate) fn run_acceptance_gates(path: &Path) -> Result { // 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() )); } return Ok(GateOutcome::build_error(msg)); } // Run script/test (or fallback to cargo test). Project-specific linting // and test commands belong in script/test. let (test_success, test_out) = run_project_tests(path)?; if !test_success { return Ok(GateOutcome::fail(test_out)); } Ok(GateOutcome::pass(test_out)) } /// 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); } } /// 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"); 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}")), }; 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) { use crate::git_test_support::git_ok; git_ok( Command::new("git") .args(["init"]) .current_dir(repo) .output(), "git init", ); git_ok( Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(repo) .output(), "git config user.email", ); git_ok( Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(repo) .output(), "git config user.name", ); git_ok( Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(repo) .output(), "git commit", ); } // ── 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")); } // ── 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"); fs::write( &script_test, "#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\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 0 should pass"); assert!( output.contains("script/test"), "output should mention script/test" ); } #[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"); assert!( output.contains("script/test"), "output should mention script/test" ); } // ── 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; use std::io::Write; 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"); { 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(); } 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; use std::io::Write; 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"); { let mut f = fs::File::create(&script).unwrap(); f.write_all( b"#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n", ) .unwrap(); f.sync_all().unwrap(); } 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)); } // ── cargo_check_in_worktree tests ──────────────────────────────────────── /// Bug 645 + 651: cargo_check_in_worktree stashes dirty files before /// checking committed code and restores them afterward. #[test] fn cargo_check_in_worktree_stashes_and_restores_dirty_files() { 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(); // Also add an untracked file. fs::write(wt_path.join("crash_residue.txt"), "untracked junk").unwrap(); // cargo_check_in_worktree should stash dirty files, check committed code, and restore. assert!( cargo_check_in_worktree(&wt_path), "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" ); } // ── 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 ); } /// Story 1101: a passing test whose name contains a lint trigger keyword /// must NOT produce a Lint classification. #[test] fn classify_does_not_false_positive_on_test_name_substring() { let output = "test agents::gates::tests::classify_lint_from_missing_doc_comments ... ok\n\ test result: ok. 1 passed; 0 failed"; assert_ne!( GateFailureKind::classify(output), GateFailureKind::Lint, "passing test name containing 'missing_doc_comments' must not classify as 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 ); } #[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 ); } #[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)); } }