use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
use car_ast::{diff_symbols, parse_file, SymbolChange, SymbolKind};
use car_eventlog::EventKind;
use car_ir::{Action, ActionType, FailureBehavior};
use serde_json::{json, Value};
use crate::shared::SharedInfra;
pub use car_ast::SymbolRef;
#[derive(Debug, Clone, Default)]
pub struct DeclaredFootprint {
allowed: HashSet<SymbolRef>,
}
impl DeclaredFootprint {
pub fn unconstrained() -> Self {
Self::default()
}
pub fn from_refs(refs: impl IntoIterator<Item = SymbolRef>) -> Self {
Self {
allowed: refs.into_iter().collect(),
}
}
pub fn is_declared(&self) -> bool {
!self.allowed.is_empty()
}
pub fn allows(&self, r: &SymbolRef) -> bool {
self.allowed.contains(r)
}
}
#[derive(Debug, Clone)]
pub struct FileChange {
pub path: String,
pub before: Option<String>,
pub after: Option<String>,
}
impl FileChange {
fn content_changed(&self) -> bool {
self.before.as_deref() != self.after.as_deref()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
Added,
Removed,
Modified,
SignatureChanged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangedSymbol {
pub file: String,
pub symbol: String,
pub change: ChangeKind,
}
impl ChangedSymbol {
fn as_ref(&self) -> SymbolRef {
SymbolRef::new(self.file.clone(), self.symbol.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContainmentViolation {
pub changed: ChangedSymbol,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuplicateDeclaration {
pub file: String,
pub symbol: String,
pub kind: String,
pub count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckOutcome {
Passed,
Failed,
NotRun,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildTestStatus {
NotConfigured,
NotRun { reason: String },
Passed,
Failed {
code: Option<i32>,
output: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyDecision {
Allow,
Deny { reasons: Vec<String> },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoVerifyWaiver {
pub class: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcceptanceBasis {
Verified,
Waived { class: String, reason: String },
}
#[derive(Debug, Clone)]
pub struct GateEvidence {
pub subtask: String,
pub changed_symbols: Vec<ChangedSymbol>,
pub footprint_declared: bool,
pub containment: CheckOutcome,
pub containment_violations: Vec<ContainmentViolation>,
pub unparsed_changed_files: Vec<String>,
pub duplicates: CheckOutcome,
pub semantic_conflicts: Vec<DuplicateDeclaration>,
pub build_test: BuildTestStatus,
pub policy: PolicyDecision,
}
#[derive(Debug, Clone)]
pub enum MergeVerdict {
Accepted {
basis: AcceptanceBasis,
evidence: GateEvidence,
},
Rejected {
reasons: Vec<String>,
evidence: GateEvidence,
},
Inconclusive {
reasons: Vec<String>,
evidence: GateEvidence,
},
}
impl MergeVerdict {
pub fn is_accepted(&self) -> bool {
matches!(self, MergeVerdict::Accepted { .. })
}
pub fn is_verified(&self) -> bool {
matches!(
self,
MergeVerdict::Accepted {
basis: AcceptanceBasis::Verified,
..
}
)
}
pub fn evidence(&self) -> &GateEvidence {
match self {
MergeVerdict::Accepted { evidence, .. }
| MergeVerdict::Rejected { evidence, .. }
| MergeVerdict::Inconclusive { evidence, .. } => evidence,
}
}
fn audit_kind(&self) -> EventKind {
if self.is_accepted() {
EventKind::GateAccepted
} else {
EventKind::GateRejected
}
}
}
#[derive(Debug, Clone)]
pub struct GateConfig {
pub subtask: String,
pub cwd: PathBuf,
pub verify_command: Option<Vec<String>>,
pub no_verify_waiver: Option<NoVerifyWaiver>,
pub verify_timeout: Duration,
pub max_output_bytes: usize,
}
impl GateConfig {
pub fn new(subtask: impl Into<String>, cwd: impl Into<PathBuf>) -> Self {
Self {
subtask: subtask.into(),
cwd: cwd.into(),
verify_command: None,
no_verify_waiver: None,
verify_timeout: Duration::from_secs(600),
max_output_bytes: 8 * 1024,
}
}
pub fn with_verify_command(mut self, cmd: Vec<String>) -> Self {
self.verify_command = Some(cmd);
self
}
pub fn with_no_verify_waiver(mut self, waiver: NoVerifyWaiver) -> Self {
self.no_verify_waiver = Some(waiver);
self
}
pub fn with_verify_timeout(mut self, timeout: Duration) -> Self {
self.verify_timeout = timeout;
self
}
}
pub fn extract_changes(changes: &[FileChange]) -> (Vec<ChangedSymbol>, Vec<String>) {
let mut symbols = Vec::new();
let mut unparsed = Vec::new();
for change in changes {
if !change.content_changed() {
continue;
}
let before_opt = change.before.as_deref().map(|s| parse_file(s, &change.path));
let after_opt = change.after.as_deref().map(|s| parse_file(s, &change.path));
let before_failed = matches!(before_opt, Some(None));
let after_failed = matches!(after_opt, Some(None));
if before_failed || after_failed {
unparsed.push(change.path.clone());
}
let before = before_opt.flatten();
let after = after_opt.flatten();
match (before, after) {
(Some(old), Some(new)) => {
for ch in diff_symbols(&old, &new) {
let (name, kind) = match ch {
SymbolChange::Added(s) => (s.name, ChangeKind::Added),
SymbolChange::Removed(s) => (s.name, ChangeKind::Removed),
SymbolChange::Modified {
new,
signature_changed,
..
} => (
new.name,
if signature_changed {
ChangeKind::SignatureChanged
} else {
ChangeKind::Modified
},
),
};
symbols.push(ChangedSymbol {
file: change.path.clone(),
symbol: name,
change: kind,
});
}
}
(None, Some(new)) => {
for s in new.all_symbols() {
symbols.push(ChangedSymbol {
file: change.path.clone(),
symbol: s.name.clone(),
change: ChangeKind::Added,
});
}
}
(Some(old), None) => {
for s in old.all_symbols() {
symbols.push(ChangedSymbol {
file: change.path.clone(),
symbol: s.name.clone(),
change: ChangeKind::Removed,
});
}
}
(None, None) => {}
}
}
(symbols, unparsed)
}
pub fn containment_violations(
changed: &[ChangedSymbol],
footprint: &DeclaredFootprint,
) -> Vec<ContainmentViolation> {
if !footprint.is_declared() {
return Vec::new();
}
changed
.iter()
.filter(|c| !footprint.allows(&c.as_ref()))
.map(|c| ContainmentViolation { changed: c.clone() })
.collect()
}
pub fn duplicate_declarations(changes: &[FileChange]) -> Vec<DuplicateDeclaration> {
let mut out = Vec::new();
for change in changes {
let Some(parsed) = change
.after
.as_deref()
.and_then(|src| parse_file(src, &change.path))
else {
continue;
};
let mut counts: HashMap<(String, SymbolKind), usize> = HashMap::new();
for sym in parsed.all_symbols() {
if matches!(sym.kind, SymbolKind::Import) {
continue;
}
*counts.entry((sym.name.clone(), sym.kind)).or_insert(0) += 1;
}
for ((name, kind), count) in counts {
if count > 1 {
out.push(DuplicateDeclaration {
file: change.path.clone(),
symbol: name,
kind: format!("{kind:?}"),
count,
});
}
}
}
out
}
pub fn decide(evidence: GateEvidence, waiver: Option<&NoVerifyWaiver>) -> MergeVerdict {
if let PolicyDecision::Deny { reasons } = &evidence.policy {
let reasons = reasons
.iter()
.map(|r| format!("policy denied integration: {r}"))
.collect();
return MergeVerdict::Rejected { reasons, evidence };
}
let mut reasons = Vec::new();
for v in &evidence.containment_violations {
reasons.push(format!(
"changed {}::{} outside declared footprint",
v.changed.file, v.changed.symbol
));
}
for d in &evidence.semantic_conflicts {
reasons.push(format!(
"{} duplicate {} declarations of {} in {}",
d.count, d.kind, d.symbol, d.file
));
}
if let BuildTestStatus::Failed { code, output } = &evidence.build_test {
reasons.push(format!(
"build/test failed (exit {code:?}): {}",
tail(output, 300)
));
}
if !reasons.is_empty() {
return MergeVerdict::Rejected { reasons, evidence };
}
match &evidence.build_test {
BuildTestStatus::Passed => MergeVerdict::Accepted {
basis: AcceptanceBasis::Verified,
evidence,
},
BuildTestStatus::NotConfigured => match waiver {
Some(w) => MergeVerdict::Accepted {
basis: AcceptanceBasis::Waived {
class: w.class.clone(),
reason: w.reason.clone(),
},
evidence,
},
None => MergeVerdict::Inconclusive {
reasons: vec![
"build/test not configured and no waiver supplied — cannot affirm safety"
.to_string(),
],
evidence,
},
},
BuildTestStatus::NotRun { reason } => MergeVerdict::Inconclusive {
reasons: vec![format!("build/test did not run: {reason}")],
evidence,
},
BuildTestStatus::Failed { .. } => MergeVerdict::Rejected {
reasons: vec!["build/test failed".to_string()],
evidence,
},
}
}
pub async fn verify_changes(
config: &GateConfig,
changes: &[FileChange],
footprint: &DeclaredFootprint,
infra: &SharedInfra,
) -> MergeVerdict {
let (changed_symbols, unparsed_changed_files) = extract_changes(changes);
let containment_list = containment_violations(&changed_symbols, footprint);
let duplicates = duplicate_declarations(changes);
let containment = if !footprint.is_declared() {
CheckOutcome::NotRun
} else if containment_list.is_empty() {
CheckOutcome::Passed
} else {
CheckOutcome::Failed
};
let duplicate_outcome = if duplicates.is_empty() {
CheckOutcome::Passed
} else {
CheckOutcome::Failed
};
let policy = consult_policy(config, changes, infra).await;
let ast_failed = !containment_list.is_empty() || !duplicates.is_empty();
let build_test = if ast_failed {
BuildTestStatus::NotRun {
reason: "AST checks already failed".to_string(),
}
} else if let PolicyDecision::Deny { .. } = &policy {
BuildTestStatus::NotRun {
reason: "policy denied integration".to_string(),
}
} else {
run_verify_command(config).await
};
let evidence = GateEvidence {
subtask: config.subtask.clone(),
changed_symbols,
footprint_declared: footprint.is_declared(),
containment,
containment_violations: containment_list,
unparsed_changed_files,
duplicates: duplicate_outcome,
semantic_conflicts: duplicates,
build_test,
policy,
};
let verdict = decide(evidence, config.no_verify_waiver.as_ref());
emit_audit(&verdict, infra).await;
verdict
}
async fn consult_policy(
config: &GateConfig,
changes: &[FileChange],
infra: &SharedInfra,
) -> PolicyDecision {
let files: Vec<Value> = changes
.iter()
.filter(|c| c.content_changed())
.map(|c| json!(c.path))
.collect();
let mut parameters = HashMap::new();
parameters.insert("subtask".to_string(), json!(config.subtask));
parameters.insert("files".to_string(), json!(files));
let action = Action {
id: format!("foreman-integrate-{}", config.subtask),
action_type: ActionType::ToolCall,
tool: Some("foreman.integrate".to_string()),
parameters,
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
read_set: vec![],
write_set: vec![],
assumptions: vec![],
idempotent: true,
max_retries: 0,
failure_behavior: FailureBehavior::Skip,
timeout_ms: None,
metadata: HashMap::new(),
};
let violations = infra.policies.read().await.check(&action, &infra.state);
if violations.is_empty() {
PolicyDecision::Allow
} else {
PolicyDecision::Deny {
reasons: violations
.into_iter()
.map(|v| format!("{}: {}", v.policy_name, v.reason))
.collect(),
}
}
}
async fn run_verify_command(config: &GateConfig) -> BuildTestStatus {
let Some(cmd) = &config.verify_command else {
return BuildTestStatus::NotConfigured;
};
let Some((program, args)) = cmd.split_first() else {
return BuildTestStatus::NotConfigured;
};
if !config.cwd.is_dir() {
return BuildTestStatus::NotRun {
reason: format!("verify cwd does not exist: {}", config.cwd.display()),
};
}
let run = tokio::process::Command::new(program)
.args(args)
.current_dir(&config.cwd)
.kill_on_drop(true)
.output();
let output = match tokio::time::timeout(config.verify_timeout, run).await {
Ok(res) => res,
Err(_) => {
return BuildTestStatus::NotRun {
reason: format!("verify command timed out after {:?}", config.verify_timeout),
};
}
};
match output {
Ok(out) if out.status.success() => BuildTestStatus::Passed,
Ok(out) => {
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
combined.push_str(&String::from_utf8_lossy(&out.stderr));
BuildTestStatus::Failed {
code: out.status.code(),
output: tail(&combined, config.max_output_bytes),
}
}
Err(e) => BuildTestStatus::Failed {
code: None,
output: format!("failed to launch verify command: {e}"),
},
}
}
async fn emit_audit(verdict: &MergeVerdict, infra: &SharedInfra) {
let evidence = verdict.evidence();
let (outcome, basis, reasons) = match verdict {
MergeVerdict::Accepted { basis, .. } => {
let basis_str = match basis {
AcceptanceBasis::Verified => "verified".to_string(),
AcceptanceBasis::Waived { class, .. } => format!("waived:{class}"),
};
("accepted", Some(basis_str), Vec::new())
}
MergeVerdict::Rejected { reasons, .. } => ("rejected", None, reasons.clone()),
MergeVerdict::Inconclusive { reasons, .. } => ("inconclusive", None, reasons.clone()),
};
let mut data = HashMap::new();
data.insert("subtask".to_string(), json!(evidence.subtask));
data.insert("outcome".to_string(), json!(outcome));
if let Some(basis) = basis {
data.insert("basis".to_string(), json!(basis));
}
data.insert(
"changed_symbols".to_string(),
json!(evidence.changed_symbols.len()),
);
data.insert(
"containment_violations".to_string(),
json!(evidence.containment_violations.len()),
);
data.insert(
"unparsed_changed_files".to_string(),
json!(evidence.unparsed_changed_files),
);
data.insert(
"semantic_conflicts".to_string(),
json!(evidence.semantic_conflicts.len()),
);
data.insert(
"build_test".to_string(),
json!(match &evidence.build_test {
BuildTestStatus::NotConfigured => "not_configured",
BuildTestStatus::NotRun { .. } => "not_run",
BuildTestStatus::Passed => "passed",
BuildTestStatus::Failed { .. } => "failed",
}),
);
if !reasons.is_empty() {
data.insert("reasons".to_string(), json!(reasons));
}
infra
.log
.lock()
.await
.append(verdict.audit_kind(), None, None, data);
}
fn tail(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let mut start = s.len() - max_bytes;
while start < s.len() && !s.is_char_boundary(start) {
start += 1;
}
format!("…[truncated]\n{}", &s[start..])
}
#[cfg(test)]
mod tests {
use super::*;
fn rs(path: &str, before: Option<&str>, after: Option<&str>) -> FileChange {
FileChange {
path: path.to_string(),
before: before.map(str::to_string),
after: after.map(str::to_string),
}
}
#[test]
fn detects_modified_and_added_symbols() {
let (changed, unparsed) = extract_changes(&[rs(
"src/lib.rs",
Some("pub fn alpha() {}\n"),
Some("pub fn alpha() -> u8 { 1 }\npub fn beta() {}\n"),
)]);
assert!(unparsed.is_empty());
let names: Vec<_> = changed.iter().map(|c| c.symbol.as_str()).collect();
assert!(names.contains(&"alpha"), "alpha changed: {names:?}");
assert!(names.contains(&"beta"), "beta added: {names:?}");
}
#[test]
fn unparseable_changed_file_is_recorded() {
let (changed, unparsed) =
extract_changes(&[rs("Cargo.toml", Some("[package]\n"), Some("[package]\nx=1\n"))]);
assert!(changed.is_empty(), "no symbols from an unparseable file");
assert_eq!(unparsed, vec!["Cargo.toml".to_string()]);
}
#[test]
fn containment_flags_out_of_footprint_edits() {
let (changed, _) = extract_changes(&[rs(
"src/lib.rs",
Some("pub fn allowed() {}\npub fn sneaky() {}\n"),
Some("pub fn allowed() -> u8 { 1 }\npub fn sneaky() -> u8 { 2 }\n"),
)]);
let footprint = DeclaredFootprint::from_refs([SymbolRef::new("src/lib.rs", "allowed")]);
let violations = containment_violations(&changed, &footprint);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].changed.symbol, "sneaky");
}
#[test]
fn duplicate_declarations_catches_method_level() {
let after = "pub struct S;\nimpl S {\n pub fn handle(&self) {}\n pub fn handle(&self) {}\n}\n";
let dups = duplicate_declarations(&[rs("src/lib.rs", Some("pub struct S;\n"), Some(after))]);
assert!(
dups.iter().any(|d| d.symbol == "handle" && d.count == 2),
"method-level duplicate must be caught: {dups:?}"
);
}
fn clean_evidence(build_test: BuildTestStatus) -> GateEvidence {
GateEvidence {
subtask: "t".to_string(),
changed_symbols: vec![],
footprint_declared: false,
containment: CheckOutcome::NotRun,
containment_violations: vec![],
unparsed_changed_files: vec![],
duplicates: CheckOutcome::Passed,
semantic_conflicts: vec![],
build_test,
policy: PolicyDecision::Allow,
}
}
#[test]
fn skipped_build_test_is_inconclusive_not_accepted() {
let verdict = decide(clean_evidence(BuildTestStatus::NotConfigured), None);
assert!(
matches!(verdict, MergeVerdict::Inconclusive { .. }),
"unconfigured build/test must be inconclusive, got {verdict:?}"
);
assert!(!verdict.is_accepted());
}
#[test]
fn passed_build_test_yields_verified_acceptance() {
let verdict = decide(clean_evidence(BuildTestStatus::Passed), None);
assert!(verdict.is_verified());
}
#[test]
fn explicit_waiver_accepts_without_build_test_but_not_verified() {
let waiver = NoVerifyWaiver {
class: "docs-only".to_string(),
reason: "README change".to_string(),
};
let verdict = decide(clean_evidence(BuildTestStatus::NotConfigured), Some(&waiver));
assert!(verdict.is_accepted(), "explicit waiver accepts");
assert!(!verdict.is_verified(), "but it is NOT build/test-verified");
}
#[test]
fn failed_build_test_rejects() {
let verdict = decide(
clean_evidence(BuildTestStatus::Failed {
code: Some(101),
output: "boom".to_string(),
}),
None,
);
assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
}
#[test]
fn policy_denial_rejects_even_with_passing_build() {
let mut ev = clean_evidence(BuildTestStatus::Passed);
ev.policy = PolicyDecision::Deny {
reasons: vec!["protected path".to_string()],
};
let verdict = decide(ev, None);
assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
}
#[test]
fn containment_violation_rejects_even_with_passing_build() {
let mut ev = clean_evidence(BuildTestStatus::Passed);
ev.containment_violations = vec![ContainmentViolation {
changed: ChangedSymbol {
file: "src/lib.rs".to_string(),
symbol: "sneaky".to_string(),
change: ChangeKind::Modified,
},
}];
assert!(matches!(decide(ev, None), MergeVerdict::Rejected { .. }));
}
#[tokio::test]
async fn verify_accepts_clean_change_with_passing_command_and_audits() {
let infra = SharedInfra::new();
let change = rs("src/lib.rs", Some("pub fn a() {}\n"), Some("pub fn a() -> u8 { 1 }\n"));
let config = GateConfig::new("subtask-1", std::env::temp_dir())
.with_verify_command(vec!["true".to_string()]);
let verdict =
verify_changes(&config, &[change], &DeclaredFootprint::unconstrained(), &infra).await;
assert!(verdict.is_verified(), "clean change + passing build = verified");
let log = infra.log.lock().await;
assert_eq!(log.events()[0].kind, EventKind::GateAccepted);
}
#[tokio::test]
async fn verify_without_command_is_inconclusive() {
let infra = SharedInfra::new();
let change = rs("src/lib.rs", Some("pub fn a() {}\n"), Some("pub fn a() -> u8 { 1 }\n"));
let config = GateConfig::new("subtask-2", std::env::temp_dir());
let verdict =
verify_changes(&config, &[change], &DeclaredFootprint::unconstrained(), &infra).await;
assert!(!verdict.is_accepted());
assert!(matches!(verdict, MergeVerdict::Inconclusive { .. }));
let log = infra.log.lock().await;
assert_eq!(log.events()[0].kind, EventKind::GateRejected);
}
#[tokio::test]
async fn verify_rejects_containment_escape_and_skips_build() {
let infra = SharedInfra::new();
let change = rs(
"src/lib.rs",
Some("pub fn allowed() {}\npub fn sneaky() {}\n"),
Some("pub fn allowed() {}\npub fn sneaky() -> u8 { 2 }\n"),
);
let footprint = DeclaredFootprint::from_refs([SymbolRef::new("src/lib.rs", "allowed")]);
let config = GateConfig::new("subtask-3", std::env::temp_dir())
.with_verify_command(vec!["true".to_string()]);
let verdict = verify_changes(&config, &[change], &footprint, &infra).await;
assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
assert!(matches!(
verdict.evidence().build_test,
BuildTestStatus::NotRun { .. }
));
}
#[tokio::test]
async fn policy_can_deny_integration() {
let infra = SharedInfra::new();
infra.policies.write().await.register(
"protect-cargo-lock",
Box::new(|action: &Action, _| {
let touches = action
.parameters
.get("files")
.and_then(|f| f.as_array())
.map(|arr| arr.iter().any(|v| v.as_str() == Some("Cargo.lock")))
.unwrap_or(false);
if touches {
Some("integration touches protected Cargo.lock".to_string())
} else {
None
}
}),
"block merges touching Cargo.lock",
);
let change = rs("Cargo.lock", Some("a = 1\n"), Some("a = 2\n"));
let config = GateConfig::new("subtask-4", std::env::temp_dir())
.with_verify_command(vec!["true".to_string()]);
let verdict =
verify_changes(&config, &[change], &DeclaredFootprint::unconstrained(), &infra).await;
assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
}
#[tokio::test]
async fn missing_verify_cwd_is_inconclusive_not_accepted() {
let infra = SharedInfra::new();
let change = rs("src/lib.rs", Some("pub fn a() {}\n"), Some("pub fn a() -> u8 { 1 }\n"));
let config = GateConfig::new("subtask-5", "/nonexistent/foreman/tree")
.with_verify_command(vec!["true".to_string()]);
let verdict =
verify_changes(&config, &[change], &DeclaredFootprint::unconstrained(), &infra).await;
assert!(!verdict.is_accepted());
assert!(matches!(verdict, MergeVerdict::Inconclusive { .. }));
}
}