use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::task::{Task, load_tasks_from_dir};
use crate::team::config::TeamConfig;
use crate::team::hierarchy::resolve_hierarchy;
use crate::team::inbox;
use crate::team::review::task_reference_mismatch_blockers;
use crate::team::task_loop::run_tests_in_worktree;
use crate::team::test_results::TestResults;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VerificationRunResult {
pub passed: bool,
pub output: String,
pub results: TestResults,
pub failures: Vec<String>,
pub file_paths: Vec<String>,
pub recovery: Option<VerificationRecovery>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum VerificationRecoveryKind {
BranchTaskMismatch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct VerificationRecovery {
pub kind: VerificationRecoveryKind,
pub expected_task_id: u32,
pub expected_branch: String,
pub actual_branch: String,
pub preserved_branches: Vec<String>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScopeValidationResult {
pub declared_scope: Vec<String>,
pub changed_files: Vec<String>,
pub out_of_scope_files: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScopeFenceCheck {
pub task_id: u32,
pub declared_scope: Vec<String>,
pub out_of_scope_files: Vec<String>,
pub ack_present: bool,
}
pub(crate) fn run_automatic_verification(
worktree_dir: &Path,
test_command: Option<&str>,
) -> Result<VerificationRunResult> {
if let Some(conflict_failure) = active_claim_conflict_failure(worktree_dir)? {
return Ok(conflict_failure);
}
if let Some(task_mismatch_failure) = task_mismatch_validation_failure(worktree_dir)? {
return Ok(task_mismatch_failure);
}
if let Some(scope_failure) = scope_validation_failure(worktree_dir)? {
return Ok(scope_failure);
}
let test_run = run_tests_in_worktree(worktree_dir, test_command)?;
let (failures, _failure_paths) =
parse_test_output(&test_run.output, &test_run.results, test_run.passed);
let file_paths =
changed_files_from_trunk(worktree_dir, &trunk_branch_for_worktree(worktree_dir)?)?;
Ok(VerificationRunResult {
passed: test_run.passed,
output: test_run.output,
results: test_run.results,
failures,
file_paths,
recovery: None,
})
}
fn active_claim_conflict_failure(worktree_dir: &Path) -> Result<Option<VerificationRunResult>> {
let Some((project_root, engineer)) = engineer_worktree_context(worktree_dir) else {
return Ok(None);
};
let tasks_dir = project_root
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let active_tasks: Vec<Task> = load_tasks_from_dir(&tasks_dir)
.with_context(|| format!("failed to load tasks from {}", tasks_dir.display()))?
.into_iter()
.filter(|task| {
task.claimed_by.as_deref() == Some(engineer.as_str())
&& matches!(task.status.as_str(), "review" | "in-progress")
})
.collect();
if active_tasks.len() <= 1 {
return Ok(None);
}
let current_branch = current_branch_name(worktree_dir).ok();
let explicit_worktree_matches = active_tasks
.iter()
.filter(|task| {
task.worktree_path
.as_deref()
.is_some_and(|path| resolve_worktree_path(&project_root, path) == worktree_dir)
})
.count();
let branch_matches = current_branch
.as_deref()
.map(|branch| {
active_tasks
.iter()
.filter(|task| {
task.branch
.as_deref()
.map(|task_branch| task_branch == branch)
.unwrap_or_else(|| format!("{engineer}/{}", task.id) == branch)
})
.count()
})
.unwrap_or(0);
if explicit_worktree_matches == 1 || branch_matches == 1 {
return Ok(None);
}
let task_list = active_tasks
.iter()
.map(|task| format!("#{}", task.id))
.collect::<Vec<_>>()
.join(", ");
let message = format!(
"ambiguous active task ownership for {engineer}: claimed tasks {task_list} do not resolve to a single worktree/task branch"
);
Ok(Some(VerificationRunResult {
passed: false,
output: message.clone(),
results: TestResults {
framework: "claim-conflict".to_string(),
total: None,
passed: 0,
failed: 1,
ignored: 0,
failures: Vec::new(),
summary: Some(message.clone()),
},
failures: vec![message],
file_paths: Vec::new(),
recovery: None,
}))
}
fn task_mismatch_validation_failure(worktree_dir: &Path) -> Result<Option<VerificationRunResult>> {
let Some((project_root, engineer)) = engineer_worktree_context(worktree_dir) else {
return Ok(None);
};
let Some(task) = find_claimed_task_for_worktree(&project_root, &engineer, worktree_dir)? else {
return Ok(None);
};
let branch_name = current_branch_name(worktree_dir)?;
let commit_messages =
commit_subjects_since_trunk(worktree_dir, &trunk_branch_for_project(&project_root)?)?;
let blockers = task_reference_mismatch_blockers(task.id, &branch_name, &commit_messages);
if blockers.is_empty() {
return Ok(None);
}
let expected_branch = recovery_branch_for_task(&project_root, &engineer, &task);
let preserved_branches = preserved_branch_candidates(&project_root, &engineer, task.id);
let preserved_summary = if preserved_branches.is_empty() {
"No preserved branch evidence was found.".to_string()
} else {
format!(
"Preserved recovery evidence: {}.",
preserved_branches
.iter()
.map(|branch| format!("`{branch}`"))
.collect::<Vec<_>>()
.join(", ")
)
};
let message = format!(
"branch recovery required for task #{}: worktree is on `{}` but expected `{}`. {} Mismatch detail: {}",
task.id,
branch_name,
expected_branch,
preserved_summary,
blockers.join("; ")
);
let recovery = VerificationRecovery {
kind: VerificationRecoveryKind::BranchTaskMismatch,
expected_task_id: task.id,
expected_branch,
actual_branch: branch_name,
preserved_branches,
message: message.clone(),
};
Ok(Some(VerificationRunResult {
passed: false,
output: message.clone(),
results: TestResults {
framework: "branch-recovery".to_string(),
total: None,
passed: 0,
failed: 1,
ignored: 0,
failures: Vec::new(),
summary: Some(message.clone()),
},
failures: vec![message],
file_paths: Vec::new(),
recovery: Some(recovery),
}))
}
fn recovery_branch_for_task(project_root: &Path, engineer: &str, task: &Task) -> String {
let declared_branch = task
.branch
.as_deref()
.map(str::trim)
.filter(|branch| !branch.is_empty())
.map(str::to_string);
if let Some(branch) = declared_branch.as_deref()
&& git_ref_exists(project_root, branch)
{
return branch.to_string();
}
if let Some(branch) = task_branch_candidates(project_root, task.id)
.into_iter()
.find(|branch| !branch.starts_with("preserved/"))
{
return branch;
}
if let Some(branch) = preserved_branch_candidates(project_root, engineer, task.id)
.into_iter()
.next()
{
return branch;
}
declared_branch.unwrap_or_else(|| format!("{engineer}/{}", task.id))
}
fn git_ref_exists(project_root: &Path, branch: &str) -> bool {
std::process::Command::new("git")
.args([
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
])
.current_dir(project_root)
.status()
.is_ok_and(|status| status.success())
}
fn task_branch_candidates(project_root: &Path, task_id: u32) -> Vec<String> {
let Ok(output) = std::process::Command::new("git")
.args(["for-each-ref", "refs/heads", "--format=%(refname:short)"])
.current_dir(project_root)
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let id = task_id.to_string();
let slash_suffix = format!("/{id}");
let task_dash = format!("task-{id}");
let task_underscore = format!("task_{id}");
let surrounded = format!("-{id}-");
let mut branches = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|branch| !branch.is_empty())
.filter(|branch| {
branch.ends_with(&slash_suffix)
|| branch.contains(&task_dash)
|| branch.contains(&task_underscore)
|| branch.contains(&surrounded)
})
.map(str::to_string)
.collect::<Vec<_>>();
branches.sort_by_key(|branch| {
(
branch.starts_with("preserved/"),
branch.starts_with("backup/"),
branch.clone(),
)
});
branches
}
fn preserved_branch_candidates(project_root: &Path, engineer: &str, task_id: u32) -> Vec<String> {
let Ok(output) = std::process::Command::new("git")
.args([
"for-each-ref",
"refs/heads/preserved",
"--format=%(refname:short)",
])
.current_dir(project_root)
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let prefix = format!("preserved/{engineer}-{task_id}-");
let fallback = format!("-{task_id}-");
let mut branches = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|branch| !branch.is_empty())
.filter(|branch| branch.starts_with(&prefix) || branch.contains(&fallback))
.map(str::to_string)
.collect::<Vec<_>>();
branches.sort();
branches
}
pub(crate) fn parse_scope_fence(task_text: &str) -> Vec<String> {
task_text
.lines()
.find_map(|line| line.trim().strip_prefix("SCOPE FENCE:"))
.map(|scope| {
scope
.split(',')
.map(|entry| entry.trim().trim_matches('`').trim_end_matches('/'))
.filter(|entry| !entry.is_empty())
.map(|entry| entry.to_string())
.collect()
})
.unwrap_or_default()
}
pub(crate) fn scope_ack_token(task_id: u32) -> String {
format!("Scope ACK #{task_id}")
}
pub(crate) fn is_scope_ack_message(body: &str, task_id: u32) -> bool {
let normalized = body.split_whitespace().collect::<Vec<_>>().join(" ");
normalized
.to_ascii_lowercase()
.contains(&scope_ack_token(task_id).to_ascii_lowercase())
}
pub(crate) fn changed_files_from_main(worktree_dir: &Path) -> Result<Vec<String>> {
changed_files_from_trunk(worktree_dir, "main")
}
pub(crate) fn changed_files_from_trunk(
worktree_dir: &Path,
trunk_branch: &str,
) -> Result<Vec<String>> {
let range = format!("{trunk_branch}..HEAD");
let output = std::process::Command::new("git")
.args(["diff", "--name-only", &range])
.current_dir(worktree_dir)
.output()
.with_context(|| format!("failed to run git diff in {}", worktree_dir.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git diff --name-only {range} failed in {}: {}",
worktree_dir.display(),
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect())
}
fn current_branch_name(worktree_dir: &Path) -> Result<String> {
let output = std::process::Command::new("git")
.args(["branch", "--show-current"])
.current_dir(worktree_dir)
.output()
.with_context(|| format!("failed to read git branch in {}", worktree_dir.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git branch --show-current failed in {}: {}",
worktree_dir.display(),
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg_attr(not(test), allow(dead_code))]
fn commit_subjects_since_main(worktree_dir: &Path) -> Result<Vec<String>> {
commit_subjects_since_trunk(worktree_dir, "main")
}
fn commit_subjects_since_trunk(worktree_dir: &Path, trunk_branch: &str) -> Result<Vec<String>> {
let range = format!("{trunk_branch}..HEAD");
let output = std::process::Command::new("git")
.args(["log", "--format=%s", &range])
.current_dir(worktree_dir)
.output()
.with_context(|| {
format!(
"failed to read commit subjects in {}",
worktree_dir.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git log --format=%s {range} failed in {}: {}",
worktree_dir.display(),
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect())
}
fn trunk_branch_for_worktree(worktree_dir: &Path) -> Result<String> {
let Some((project_root, _)) = engineer_worktree_context(worktree_dir) else {
return Ok("main".to_string());
};
trunk_branch_for_project(&project_root)
}
fn trunk_branch_for_project(project_root: &Path) -> Result<String> {
let team_config_path = project_root
.join(".batty")
.join("team_config")
.join("team.yaml");
if !team_config_path.is_file() {
return Ok("main".to_string());
}
let team_config = TeamConfig::load(&team_config_path)
.with_context(|| format!("failed to load {}", team_config_path.display()))?;
Ok(team_config.trunk_branch().to_string())
}
pub(crate) fn validate_declared_scope(
task_text: &str,
changed_files: &[String],
) -> ScopeValidationResult {
let declared_scope = parse_scope_fence(task_text);
let out_of_scope_files = if declared_scope.is_empty() {
Vec::new()
} else {
changed_files
.iter()
.filter(|path| !path_within_scope(path, &declared_scope))
.cloned()
.collect()
};
ScopeValidationResult {
declared_scope,
changed_files: changed_files.to_vec(),
out_of_scope_files,
}
}
fn scope_acknowledged(
project_root: &Path,
engineer: &str,
task_id: u32,
claimed_at: Option<&str>,
) -> Result<bool> {
let inbox_root = project_root.join(".batty").join("inboxes");
if !inbox_root.is_dir() {
return Ok(false);
}
let Some(ack_recipient) = scope_ack_recipient(project_root, engineer)? else {
return Ok(false);
};
if !inbox_root.join(&ack_recipient).is_dir() {
return Ok(false);
}
let claimed_after = claimed_at
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.timestamp().max(0) as u64);
for (message, _) in inbox::all_messages(&inbox_root, &ack_recipient)? {
if message.from != engineer {
continue;
}
if claimed_after.is_some_and(|cutoff| message.timestamp < cutoff) {
continue;
}
if is_scope_ack_message(&message.body, task_id) {
return Ok(true);
}
}
Ok(false)
}
fn scope_ack_recipient(project_root: &Path, engineer: &str) -> Result<Option<String>> {
let team_config_path = project_root
.join(".batty")
.join("team_config")
.join("team.yaml");
let team_config = TeamConfig::load(&team_config_path)
.with_context(|| format!("failed to load {}", team_config_path.display()))?;
Ok(resolve_hierarchy(&team_config)?
.into_iter()
.find(|member| member.name == engineer)
.and_then(|member| member.reports_to))
}
pub(crate) fn inspect_scope_fence(worktree_dir: &Path) -> Result<Option<ScopeFenceCheck>> {
let Some((project_root, engineer)) = engineer_worktree_context(worktree_dir) else {
return Ok(None);
};
let Some(task) = find_claimed_task_for_worktree(&project_root, &engineer, worktree_dir)? else {
return Ok(None);
};
let task_text = std::fs::read_to_string(&task.source_path)
.with_context(|| format!("failed to read {}", task.source_path.display()))?;
let changed_files =
changed_files_from_trunk(worktree_dir, &trunk_branch_for_project(&project_root)?)?;
let scope = validate_declared_scope(&task_text, &changed_files);
if scope.declared_scope.is_empty() {
return Ok(None);
}
let ack_present = scope_acknowledged(
&project_root,
&engineer,
task.id,
task.claimed_at.as_deref(),
)?;
Ok(Some(ScopeFenceCheck {
task_id: task.id,
declared_scope: scope.declared_scope,
out_of_scope_files: scope.out_of_scope_files,
ack_present,
}))
}
fn scope_validation_failure(worktree_dir: &Path) -> Result<Option<VerificationRunResult>> {
let Some(scope) = inspect_scope_fence(worktree_dir)? else {
return Ok(None);
};
if !scope.ack_present {
let token = scope_ack_token(scope.task_id);
let message = format!(
"scope ack missing for task #{}: send `{token}` to the assigning manager or architect before writing files inside the task fence",
scope.task_id
);
return Ok(Some(VerificationRunResult {
passed: false,
output: message.clone(),
results: TestResults {
framework: "scope-ack".to_string(),
total: None,
passed: 0,
failed: 1,
ignored: 0,
failures: Vec::new(),
summary: Some(message.clone()),
},
failures: vec![message],
file_paths: scope.declared_scope,
recovery: None,
}));
}
if scope.out_of_scope_files.is_empty() {
return Ok(None);
}
let message = format!(
"scope fence violation for task #{}: changed files outside declared scope: {}",
scope.task_id,
scope.out_of_scope_files.join(", ")
);
Ok(Some(VerificationRunResult {
passed: false,
output: message.clone(),
results: TestResults {
framework: "scope-fence".to_string(),
total: None,
passed: 0,
failed: 1,
ignored: 0,
failures: Vec::new(),
summary: Some(message.clone()),
},
failures: vec![message],
file_paths: scope.out_of_scope_files,
recovery: None,
}))
}
fn engineer_worktree_context(worktree_dir: &Path) -> Option<(PathBuf, String)> {
let engineer = worktree_dir.file_name()?.to_str()?.to_string();
let worktrees_dir = worktree_dir.parent()?;
if worktrees_dir.file_name()?.to_str()? != "worktrees" {
return None;
}
let batty_dir = worktrees_dir.parent()?;
if batty_dir.file_name()?.to_str()? != ".batty" {
return None;
}
Some((batty_dir.parent()?.to_path_buf(), engineer))
}
fn find_claimed_task_for_worktree(
project_root: &Path,
engineer: &str,
worktree_dir: &Path,
) -> Result<Option<Task>> {
let tasks_dir = project_root
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let mut tasks = load_tasks_from_dir(&tasks_dir)
.with_context(|| format!("failed to load tasks from {}", tasks_dir.display()))?;
tasks.retain(|task| task.claimed_by.as_deref() == Some(engineer));
tasks.sort_by_key(task_scope_priority);
let active_tasks: Vec<Task> = tasks
.into_iter()
.filter(|task| matches!(task.status.as_str(), "review" | "in-progress"))
.collect();
let current_branch = current_branch_name(worktree_dir).ok();
if let Some(branch) = current_branch.as_deref() {
let mut branch_matches = active_tasks
.iter()
.filter(|task| task_matches_branch(engineer, task, branch))
.map(|task| task.id);
if let Some(task_id) = branch_matches.next()
&& branch_matches.next().is_none()
{
return Ok(active_tasks.into_iter().find(|task| task.id == task_id));
}
}
let mut worktree_matches = active_tasks
.iter()
.filter(|task| task_matches_worktree(project_root, task, worktree_dir))
.map(|task| task.id);
if let Some(task_id) = worktree_matches.next()
&& worktree_matches.next().is_none()
{
return Ok(active_tasks.into_iter().find(|task| task.id == task_id));
}
Ok((active_tasks.len() == 1)
.then(|| active_tasks.into_iter().next())
.flatten())
}
fn task_scope_priority(task: &Task) -> (u8, u32) {
let status_rank = match task.status.as_str() {
"review" => 0,
"in-progress" => 1,
_ => 2,
};
(status_rank, task.id)
}
fn task_matches_worktree(project_root: &Path, task: &Task, worktree_dir: &Path) -> bool {
task.worktree_path
.as_deref()
.map(|path| resolve_worktree_path(project_root, path) == worktree_dir)
.unwrap_or(true)
}
fn task_matches_branch(engineer: &str, task: &Task, branch: &str) -> bool {
task.branch
.as_deref()
.map(|task_branch| task_branch == branch)
.unwrap_or_else(|| format!("{engineer}/{}", task.id) == branch)
}
fn resolve_worktree_path(project_root: &Path, worktree_path: &str) -> PathBuf {
let candidate = PathBuf::from(worktree_path);
if candidate.is_absolute() {
candidate
} else {
project_root.join(candidate)
}
}
fn parse_test_output(
output: &str,
results: &TestResults,
passed: bool,
) -> (Vec<String>, Vec<String>) {
let mut failures = Vec::new();
let mut file_paths = BTreeSet::new();
for failure in &results.failures {
let mut detail = failure.test_name.clone();
if let Some(message) = failure
.message
.as_deref()
.filter(|message| !message.is_empty())
{
detail.push_str(": ");
detail.push_str(message);
}
if let Some(location) = failure
.location
.as_deref()
.filter(|location| !location.is_empty())
{
detail.push_str(" @ ");
detail.push_str(location);
let normalized = normalize_path_token(location);
if looks_like_path(normalized) {
file_paths.insert(normalized.to_string());
}
}
failures.push(detail);
}
for line in output.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if failures.is_empty()
&& (trimmed.starts_with("test ") && trimmed.ends_with("FAILED")
|| trimmed.starts_with("error:")
|| trimmed.contains("panicked at"))
{
failures.push(trimmed.to_string());
}
for token in trimmed.split_whitespace() {
let cleaned = token.trim_matches(|ch: char| {
matches!(ch, '"' | '\'' | ',' | ':' | ';' | '(' | ')' | '[' | ']')
});
let normalized = normalize_path_token(cleaned);
if looks_like_path(normalized) {
file_paths.insert(normalized.to_string());
}
}
}
if failures.is_empty() && !passed && !output.trim().is_empty() {
failures.push("test command failed without a parsed failure line".to_string());
}
(failures, file_paths.into_iter().collect())
}
fn normalize_path_token(token: &str) -> &str {
let mut candidate = token;
while let Some((head, tail)) = candidate.rsplit_once(':') {
if tail.chars().all(|ch| ch.is_ascii_digit()) {
candidate = head;
} else {
break;
}
}
candidate
}
fn looks_like_path(token: &str) -> bool {
let has_separator = token.contains('/') || token.contains('\\');
let has_extension = token.rsplit_once('.').is_some_and(|(_, ext)| {
!ext.is_empty() && ext.chars().all(|ch| ch.is_ascii_alphanumeric())
});
has_separator && has_extension
}
fn path_within_scope(path: &str, scope_entries: &[String]) -> bool {
scope_entries.iter().any(|scope| {
path == scope
|| path
.strip_prefix(scope)
.is_some_and(|rest| rest.starts_with('/'))
})
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use crate::team::inbox;
use crate::team::test_results::{TestFailure, TestResults};
use super::{
ScopeValidationResult, active_claim_conflict_failure, commit_subjects_since_main,
current_branch_name, engineer_worktree_context, find_claimed_task_for_worktree,
inspect_scope_fence, is_scope_ack_message, parse_scope_fence, parse_test_output,
run_automatic_verification, scope_validation_failure, task_mismatch_validation_failure,
validate_declared_scope,
};
fn write_scope_ack_team_config(project_root: &Path) {
let team_config_dir = project_root.join(".batty").join("team_config");
std::fs::create_dir_all(&team_config_dir).unwrap();
std::fs::write(
team_config_dir.join("team.yaml"),
"name: scope-test\nroles:\n - name: architect\n role_type: architect\n agent: claude\n instances: 1\n - name: manager\n role_type: manager\n agent: claude\n instances: 1\n - name: engineer\n role_type: engineer\n agent: codex\n instances: 3\n",
)
.unwrap();
}
#[test]
fn parse_test_output_extracts_failures_and_paths() {
let output = "\
test parser::it_works ... FAILED\n\
error: could not compile crate due to previous error\n\
src/parser.rs:12: failure here\n";
let results = TestResults {
framework: "cargo".to_string(),
total: Some(1),
passed: 0,
failed: 1,
ignored: 0,
failures: vec![TestFailure {
test_name: "parser::it_works".to_string(),
message: Some("assertion failed".to_string()),
location: Some("src/parser.rs:12:5".to_string()),
}],
summary: Some("test result: FAILED. 0 passed; 1 failed; 0 ignored;".to_string()),
};
let (failures, paths) = parse_test_output(output, &results, false);
assert!(
failures
.iter()
.any(|line| line.contains("parser::it_works"))
);
assert!(
failures
.iter()
.any(|line| line.contains("assertion failed"))
);
assert!(paths.iter().any(|path| path == "src/parser.rs"));
}
#[test]
fn parse_test_output_ignores_non_failure_output_for_passing_runs() {
let output = "Finished verification override successfully\n";
let results = TestResults {
framework: "cargo".to_string(),
total: Some(1),
passed: 1,
failed: 0,
ignored: 0,
failures: Vec::new(),
summary: Some("test result: ok. 1 passed; 0 failed; 0 ignored;".to_string()),
};
let (failures, paths) = parse_test_output(output, &results, true);
assert!(failures.is_empty());
assert!(paths.is_empty());
}
#[test]
fn parse_test_output_keeps_fallback_for_unparsed_failed_runs() {
let output = "verification override failed\n";
let results = TestResults {
framework: "cargo".to_string(),
total: None,
passed: 0,
failed: 1,
ignored: 0,
failures: Vec::new(),
summary: None,
};
let (failures, _paths) = parse_test_output(output, &results, false);
assert_eq!(
failures,
vec!["test command failed without a parsed failure line".to_string()]
);
}
#[test]
fn parse_scope_fence_extracts_entries() {
let task_text =
"Task\nSCOPE FENCE: src/team/completion.rs, src/team/review.rs, src/team/daemon/\n";
assert_eq!(
parse_scope_fence(task_text),
vec![
"src/team/completion.rs".to_string(),
"src/team/review.rs".to_string(),
"src/team/daemon".to_string(),
]
);
}
#[test]
fn validate_declared_scope_reports_out_of_scope_files() {
let result = validate_declared_scope(
"SCOPE FENCE: src/team/completion.rs, src/team/review.rs",
&[
"src/team/completion.rs".to_string(),
"src/team/daemon/poll.rs".to_string(),
],
);
assert_eq!(
result,
ScopeValidationResult {
declared_scope: vec![
"src/team/completion.rs".to_string(),
"src/team/review.rs".to_string(),
],
changed_files: vec![
"src/team/completion.rs".to_string(),
"src/team/daemon/poll.rs".to_string(),
],
out_of_scope_files: vec!["src/team/daemon/poll.rs".to_string()],
}
);
}
#[test]
fn engineer_worktree_context_detects_batty_worktrees() {
let context = engineer_worktree_context(Path::new("/tmp/project/.batty/worktrees/eng-1-3"))
.expect("batty worktree should resolve");
assert_eq!(context.0, PathBuf::from("/tmp/project"));
assert_eq!(context.1, "eng-1-3");
assert!(engineer_worktree_context(Path::new("/tmp/project")).is_none());
}
#[test]
fn find_claimed_task_for_worktree_prefers_matching_review_task() {
let tmp = tempfile::tempdir().unwrap();
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
std::fs::create_dir_all(&tasks_dir).unwrap();
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&worktree_dir).unwrap();
std::fs::write(
tasks_dir.join("010-task.md"),
"---\nid: 10\ntitle: other\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\nworktree_path: .batty/worktrees/eng-1-9\n---\n\nTask body.\n",
)
.unwrap();
std::fs::write(
tasks_dir.join("011-task.md"),
"---\nid: 11\ntitle: target\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\nworktree_path: .batty/worktrees/eng-1-3\n---\n\nTask body.\n",
)
.unwrap();
let task = find_claimed_task_for_worktree(tmp.path(), "eng-1-3", &worktree_dir)
.unwrap()
.expect("matching task should be found");
assert_eq!(task.id, 11);
}
#[test]
fn find_claimed_task_for_worktree_prefers_unique_branch_match_over_review_priority() {
let tmp = tempfile::tempdir().unwrap();
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::create_dir_all(worktree_dir.join("src")).unwrap();
std::fs::write(
tasks_dir.join("045-task.md"),
"---\nid: 45\ntitle: prior-review\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\nbranch: eng-1-3/45\nworktree_path: .batty/worktrees/eng-1-3\n---\n\nTask body.\n",
)
.unwrap();
std::fs::write(
tasks_dir.join("046-task.md"),
"---\nid: 46\ntitle: current-task\nstatus: in-progress\npriority: medium\nclaimed_by: eng-1-3\nbranch: eng-1-3/46\nworktree_path: .batty/worktrees/eng-1-3\n---\n\nTask body.\n",
)
.unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&worktree_dir)
.output()
.unwrap()
};
assert!(git(&["init"]).status.success());
assert!(
git(&["config", "user.email", "test@example.com"])
.status
.success()
);
assert!(git(&["config", "user.name", "Test"]).status.success());
std::fs::write(worktree_dir.join("src/lib.rs"), "pub fn base() {}\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "base"]).status.success());
assert!(git(&["branch", "-M", "main"]).status.success());
assert!(git(&["checkout", "-b", "eng-1-3/46"]).status.success());
let task = find_claimed_task_for_worktree(tmp.path(), "eng-1-3", &worktree_dir)
.unwrap()
.expect("branch-matched task should be found");
assert_eq!(task.id, 46);
}
#[test]
fn active_claim_conflict_failure_reports_ambiguous_claimed_tasks() {
let tmp = tempfile::tempdir().unwrap();
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::create_dir_all(&worktree_dir).unwrap();
std::fs::write(
tasks_dir.join("010-task.md"),
"---\nid: 10\ntitle: first\nstatus: in-progress\npriority: medium\nclaimed_by: eng-1-3\n---\n\nTask body.\n",
)
.unwrap();
std::fs::write(
tasks_dir.join("011-task.md"),
"---\nid: 11\ntitle: second\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\n---\n\nTask body.\n",
)
.unwrap();
let result = active_claim_conflict_failure(&worktree_dir)
.unwrap()
.expect("ambiguous claimed tasks should fail verification");
assert!(!result.passed);
assert!(
result
.output
.contains("ambiguous active task ownership for eng-1-3")
);
}
#[test]
fn scope_fence_validation_failure_reports_out_of_scope_files() {
let tmp = tempfile::tempdir().unwrap();
write_scope_ack_team_config(tmp.path());
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let inbox_root = tmp.path().join(".batty").join("inboxes");
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::create_dir_all(worktree_dir.join("src/team")).unwrap();
inbox::init_inbox(&inbox_root, "manager").unwrap();
std::fs::write(
tasks_dir.join("011-task.md"),
"---\nid: 11\ntitle: target\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\nclaimed_at: 2026-04-10T12:00:00Z\n---\n\nTask body.\nSCOPE FENCE: src/team/completion.rs, src/team/review.rs\n",
)
.unwrap();
let ack = inbox::InboxMessage::new_send("eng-1-3", "manager", "Scope ACK #11");
inbox::deliver_to_inbox(&inbox_root, &ack).unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&worktree_dir)
.output()
.unwrap()
};
assert!(git(&["init"]).status.success());
assert!(
git(&["config", "user.email", "test@example.com"])
.status
.success()
);
assert!(git(&["config", "user.name", "Test"]).status.success());
std::fs::write(worktree_dir.join("src/team/completion.rs"), "base\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "base"]).status.success());
assert!(git(&["branch", "-M", "main"]).status.success());
assert!(git(&["checkout", "-b", "eng-1-3"]).status.success());
std::fs::write(worktree_dir.join("src/team/review.rs"), "in scope\n").unwrap();
std::fs::write(worktree_dir.join("src/team/daemon.rs"), "out of scope\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "change"]).status.success());
let result = scope_validation_failure(&worktree_dir)
.unwrap()
.expect("scope violation should be reported");
assert!(!result.passed);
assert!(result.output.contains("scope fence violation"));
assert_eq!(result.file_paths, vec!["src/team/daemon.rs".to_string()]);
}
#[test]
fn scope_ack_message_detects_expected_token() {
assert!(is_scope_ack_message("Scope ACK #587", 587));
assert!(is_scope_ack_message(
"batty send manager \"Scope ACK #587\"",
587
));
assert!(!is_scope_ack_message("acknowledged", 587));
}
#[test]
fn inspect_scope_fence_reports_missing_ack() {
let tmp = tempfile::tempdir().unwrap();
write_scope_ack_team_config(tmp.path());
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::create_dir_all(worktree_dir.join("src/team")).unwrap();
std::fs::write(
tasks_dir.join("011-task.md"),
"---\nid: 11\ntitle: target\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\nclaimed_at: 2026-04-10T12:00:00Z\n---\n\nTask body.\nSCOPE FENCE: src/team/completion.rs\n",
)
.unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&worktree_dir)
.output()
.unwrap()
};
assert!(git(&["init"]).status.success());
assert!(
git(&["config", "user.email", "test@example.com"])
.status
.success()
);
assert!(git(&["config", "user.name", "Test"]).status.success());
std::fs::write(worktree_dir.join("src/team/completion.rs"), "base\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "base"]).status.success());
assert!(git(&["branch", "-M", "main"]).status.success());
assert!(git(&["checkout", "-b", "eng-1-3"]).status.success());
std::fs::write(worktree_dir.join("src/team/completion.rs"), "changed\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "change"]).status.success());
let result = inspect_scope_fence(&worktree_dir)
.unwrap()
.expect("scope fence should be present");
assert_eq!(result.task_id, 11);
assert!(!result.ack_present);
assert!(result.out_of_scope_files.is_empty());
let failure = scope_validation_failure(&worktree_dir)
.unwrap()
.expect("missing ack should fail");
assert!(failure.output.contains("scope ack missing"));
}
#[test]
fn inspect_scope_fence_accepts_matching_ack() {
let tmp = tempfile::tempdir().unwrap();
write_scope_ack_team_config(tmp.path());
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let inbox_root = tmp.path().join(".batty").join("inboxes");
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::create_dir_all(worktree_dir.join("src/team")).unwrap();
inbox::init_inbox(&inbox_root, "manager").unwrap();
std::fs::write(
tasks_dir.join("011-task.md"),
"---\nid: 11\ntitle: target\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\nclaimed_at: 2026-04-10T12:00:00Z\n---\n\nTask body.\nSCOPE FENCE: src/team/completion.rs\n",
)
.unwrap();
let ack = inbox::InboxMessage::new_send("eng-1-3", "manager", "Scope ACK #11");
inbox::deliver_to_inbox(&inbox_root, &ack).unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&worktree_dir)
.output()
.unwrap()
};
assert!(git(&["init"]).status.success());
assert!(
git(&["config", "user.email", "test@example.com"])
.status
.success()
);
assert!(git(&["config", "user.name", "Test"]).status.success());
std::fs::write(worktree_dir.join("src/team/completion.rs"), "base\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "base"]).status.success());
assert!(git(&["branch", "-M", "main"]).status.success());
assert!(git(&["checkout", "-b", "eng-1-3"]).status.success());
std::fs::write(worktree_dir.join("src/team/completion.rs"), "changed\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "change"]).status.success());
let result = inspect_scope_fence(&worktree_dir)
.unwrap()
.expect("scope fence should be present");
assert!(result.ack_present);
assert!(result.out_of_scope_files.is_empty());
}
#[test]
fn inspect_scope_fence_rejects_ack_sent_to_wrong_inbox() {
let tmp = tempfile::tempdir().unwrap();
write_scope_ack_team_config(tmp.path());
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let inbox_root = tmp.path().join(".batty").join("inboxes");
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::create_dir_all(worktree_dir.join("src/team")).unwrap();
inbox::init_inbox(&inbox_root, "manager").unwrap();
inbox::init_inbox(&inbox_root, "architect").unwrap();
std::fs::write(
tasks_dir.join("011-task.md"),
"---\nid: 11\ntitle: target\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\nclaimed_at: 2026-04-10T12:00:00Z\n---\n\nTask body.\nSCOPE FENCE: src/team/completion.rs\n",
)
.unwrap();
let ack = inbox::InboxMessage::new_send("eng-1-3", "architect", "Scope ACK #11");
inbox::deliver_to_inbox(&inbox_root, &ack).unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&worktree_dir)
.output()
.unwrap()
};
assert!(git(&["init"]).status.success());
assert!(
git(&["config", "user.email", "test@example.com"])
.status
.success()
);
assert!(git(&["config", "user.name", "Test"]).status.success());
std::fs::write(worktree_dir.join("src/team/completion.rs"), "base\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "base"]).status.success());
assert!(git(&["branch", "-M", "main"]).status.success());
assert!(git(&["checkout", "-b", "eng-1-3"]).status.success());
std::fs::write(worktree_dir.join("src/team/completion.rs"), "changed\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "change"]).status.success());
let result = inspect_scope_fence(&worktree_dir)
.unwrap()
.expect("scope fence should be present");
assert!(!result.ack_present);
}
#[test]
fn task_mismatch_validation_failure_reports_wrong_branch_and_commit_task_ids() {
let tmp = tempfile::tempdir().unwrap();
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::create_dir_all(worktree_dir.join("src/team")).unwrap();
std::fs::write(
tasks_dir.join("011-task.md"),
"---\nid: 11\ntitle: target\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\n---\n\nTask body.\n",
)
.unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&worktree_dir)
.output()
.unwrap()
};
assert!(git(&["init"]).status.success());
assert!(
git(&["config", "user.email", "test@example.com"])
.status
.success()
);
assert!(git(&["config", "user.name", "Test"]).status.success());
std::fs::write(worktree_dir.join("src/team/review.rs"), "base\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "base"]).status.success());
assert!(git(&["branch", "-M", "main"]).status.success());
assert!(
git(&["checkout", "-b", "eng-1-3/task-449"])
.status
.success()
);
std::fs::write(worktree_dir.join("src/team/review.rs"), "changed\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(
git(&["commit", "-m", "Task #449: implement wrong task"])
.status
.success()
);
let result = task_mismatch_validation_failure(&worktree_dir)
.unwrap()
.expect("task mismatch should be reported");
assert!(!result.passed);
assert!(result.output.contains("branch recovery required"));
assert!(result.output.contains("assigned task is #11"));
assert!(result.output.contains("#449"));
assert!(result.recovery.is_some());
}
#[test]
fn task_mismatch_validation_failure_allows_expected_task_references() {
let tmp = tempfile::tempdir().unwrap();
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::create_dir_all(worktree_dir.join("src/team")).unwrap();
std::fs::write(
tasks_dir.join("011-task.md"),
"---\nid: 11\ntitle: target\nstatus: review\npriority: medium\nclaimed_by: eng-1-3\n---\n\nTask body.\n",
)
.unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&worktree_dir)
.output()
.unwrap()
};
assert!(git(&["init"]).status.success());
assert!(
git(&["config", "user.email", "test@example.com"])
.status
.success()
);
assert!(git(&["config", "user.name", "Test"]).status.success());
std::fs::write(worktree_dir.join("src/team/review.rs"), "base\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "base"]).status.success());
assert!(git(&["branch", "-M", "main"]).status.success());
assert!(git(&["checkout", "-b", "eng-1-3/task-11"]).status.success());
std::fs::write(worktree_dir.join("src/team/review.rs"), "changed\n").unwrap();
assert!(git(&["add", "."]).status.success());
assert!(
git(&["commit", "-m", "Task #11: implement expected task"])
.status
.success()
);
assert!(
task_mismatch_validation_failure(&worktree_dir)
.unwrap()
.is_none()
);
assert_eq!(
current_branch_name(&worktree_dir).unwrap(),
"eng-1-3/task-11"
);
assert_eq!(
commit_subjects_since_main(&worktree_dir).unwrap(),
vec!["Task #11: implement expected task".to_string()]
);
}
#[test]
fn run_automatic_verification_uses_git_diff_paths_not_test_output_paths() {
let tmp = tempfile::tempdir().unwrap();
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
let worktree_dir = tmp.path().join(".batty").join("worktrees").join("eng-1-3");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::create_dir_all(worktree_dir.join("src")).unwrap();
std::fs::write(
tasks_dir.join("011-task.md"),
"---\nid: 11\ntitle: target\nstatus: in-progress\npriority: medium\nclaimed_by: eng-1-3\n---\n\nTask body.\n",
)
.unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&worktree_dir)
.output()
.unwrap()
};
assert!(git(&["init"]).status.success());
assert!(
git(&["config", "user.email", "test@example.com"])
.status
.success()
);
assert!(git(&["config", "user.name", "Test"]).status.success());
std::fs::write(
worktree_dir.join("src").join("owned.rs"),
"pub fn owned() {}\n",
)
.unwrap();
assert!(git(&["add", "."]).status.success());
assert!(git(&["commit", "-m", "base"]).status.success());
assert!(git(&["branch", "-M", "main"]).status.success());
assert!(git(&["checkout", "-b", "eng-1-3/task-11"]).status.success());
std::fs::write(
worktree_dir.join("src").join("owned.rs"),
"pub fn owned() -> bool { true }\n",
)
.unwrap();
assert!(git(&["add", "src/owned.rs"]).status.success());
assert!(
git(&["commit", "-m", "Task #11: change owned"])
.status
.success()
);
let result = run_automatic_verification(
&worktree_dir,
Some("printf 'error: src/noisy.rs:9:1 boom\\n'; exit 1"),
)
.unwrap();
assert_eq!(result.file_paths, vec!["src/owned.rs".to_string()]);
}
}