1use std::collections::HashMap;
49use std::collections::HashSet;
50use std::path::PathBuf;
51use std::time::Duration;
52
53use car_ast::{diff_symbols, parse_file, SymbolChange, SymbolKind};
54use car_eventlog::EventKind;
55use car_ir::{Action, ActionType, FailureBehavior};
56use serde_json::{json, Value};
57
58use crate::shared::SharedInfra;
59
60pub use car_ast::SymbolRef;
64
65#[derive(Debug, Clone, Default)]
69pub struct DeclaredFootprint {
70 allowed: HashSet<SymbolRef>,
71}
72
73impl DeclaredFootprint {
74 pub fn unconstrained() -> Self {
76 Self::default()
77 }
78
79 pub fn from_refs(refs: impl IntoIterator<Item = SymbolRef>) -> Self {
80 Self {
81 allowed: refs.into_iter().collect(),
82 }
83 }
84
85 pub fn is_declared(&self) -> bool {
86 !self.allowed.is_empty()
87 }
88
89 pub fn allows(&self, r: &SymbolRef) -> bool {
90 self.allowed.contains(r)
91 }
92}
93
94#[derive(Debug, Clone)]
97pub struct FileChange {
98 pub path: String,
99 pub before: Option<String>,
100 pub after: Option<String>,
101}
102
103impl FileChange {
104 fn content_changed(&self) -> bool {
105 self.before.as_deref() != self.after.as_deref()
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum ChangeKind {
112 Added,
113 Removed,
114 Modified,
115 SignatureChanged,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ChangedSymbol {
121 pub file: String,
122 pub symbol: String,
123 pub change: ChangeKind,
124}
125
126impl ChangedSymbol {
127 fn as_ref(&self) -> SymbolRef {
128 SymbolRef::new(self.file.clone(), self.symbol.clone())
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct ContainmentViolation {
135 pub changed: ChangedSymbol,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct DuplicateDeclaration {
141 pub file: String,
142 pub symbol: String,
143 pub kind: String,
144 pub count: usize,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum CheckOutcome {
150 Passed,
151 Failed,
152 NotRun,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum BuildTestStatus {
161 NotConfigured,
162 NotRun {
163 reason: String,
164 },
165 Passed,
166 Failed {
167 code: Option<i32>,
168 output: String,
170 },
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum PolicyDecision {
176 Allow,
177 Deny { reasons: Vec<String> },
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct NoVerifyWaiver {
186 pub class: String,
187 pub reason: String,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum AcceptanceBasis {
194 Verified,
196 Waived { class: String, reason: String },
198}
199
200#[derive(Debug, Clone)]
204pub struct GateEvidence {
205 pub subtask: String,
206 pub changed_symbols: Vec<ChangedSymbol>,
207 pub footprint_declared: bool,
208 pub containment: CheckOutcome,
209 pub containment_violations: Vec<ContainmentViolation>,
210 pub unparsed_changed_files: Vec<String>,
214 pub duplicates: CheckOutcome,
215 pub semantic_conflicts: Vec<DuplicateDeclaration>,
216 pub build_test: BuildTestStatus,
217 pub policy: PolicyDecision,
218}
219
220#[derive(Debug, Clone)]
225pub enum MergeVerdict {
226 Accepted {
227 basis: AcceptanceBasis,
228 evidence: GateEvidence,
229 },
230 Rejected {
231 reasons: Vec<String>,
232 evidence: GateEvidence,
233 },
234 Inconclusive {
235 reasons: Vec<String>,
236 evidence: GateEvidence,
237 },
238}
239
240impl MergeVerdict {
241 pub fn is_accepted(&self) -> bool {
242 matches!(self, MergeVerdict::Accepted { .. })
243 }
244
245 pub fn is_verified(&self) -> bool {
248 matches!(
249 self,
250 MergeVerdict::Accepted {
251 basis: AcceptanceBasis::Verified,
252 ..
253 }
254 )
255 }
256
257 pub fn evidence(&self) -> &GateEvidence {
258 match self {
259 MergeVerdict::Accepted { evidence, .. }
260 | MergeVerdict::Rejected { evidence, .. }
261 | MergeVerdict::Inconclusive { evidence, .. } => evidence,
262 }
263 }
264
265 fn audit_kind(&self) -> EventKind {
266 if self.is_accepted() {
267 EventKind::GateAccepted
268 } else {
269 EventKind::GateRejected
270 }
271 }
272}
273
274#[derive(Debug, Clone)]
276pub struct GateConfig {
277 pub subtask: String,
279 pub cwd: PathBuf,
281 pub verify_command: Option<Vec<String>>,
285 pub no_verify_waiver: Option<NoVerifyWaiver>,
288 pub verify_timeout: Duration,
291 pub max_output_bytes: usize,
293}
294
295impl GateConfig {
296 pub fn new(subtask: impl Into<String>, cwd: impl Into<PathBuf>) -> Self {
297 Self {
298 subtask: subtask.into(),
299 cwd: cwd.into(),
300 verify_command: None,
301 no_verify_waiver: None,
302 verify_timeout: Duration::from_secs(600),
303 max_output_bytes: 8 * 1024,
304 }
305 }
306
307 pub fn with_verify_command(mut self, cmd: Vec<String>) -> Self {
308 self.verify_command = Some(cmd);
309 self
310 }
311
312 pub fn with_no_verify_waiver(mut self, waiver: NoVerifyWaiver) -> Self {
313 self.no_verify_waiver = Some(waiver);
314 self
315 }
316
317 pub fn with_verify_timeout(mut self, timeout: Duration) -> Self {
318 self.verify_timeout = timeout;
319 self
320 }
321}
322
323pub fn extract_changes(changes: &[FileChange]) -> (Vec<ChangedSymbol>, Vec<String>) {
331 let mut symbols = Vec::new();
332 let mut unparsed = Vec::new();
333
334 for change in changes {
335 if !change.content_changed() {
336 continue;
337 }
338 let before_opt = change
339 .before
340 .as_deref()
341 .map(|s| parse_file(s, &change.path));
342 let after_opt = change.after.as_deref().map(|s| parse_file(s, &change.path));
343
344 let before_failed = matches!(before_opt, Some(None));
347 let after_failed = matches!(after_opt, Some(None));
348 if before_failed || after_failed {
349 unparsed.push(change.path.clone());
350 }
351
352 let before = before_opt.flatten();
353 let after = after_opt.flatten();
354 match (before, after) {
355 (Some(old), Some(new)) => {
356 for ch in diff_symbols(&old, &new) {
357 let (name, kind) = match ch {
358 SymbolChange::Added(s) => (s.name, ChangeKind::Added),
359 SymbolChange::Removed(s) => (s.name, ChangeKind::Removed),
360 SymbolChange::Modified {
361 new,
362 signature_changed,
363 ..
364 } => (
365 new.name,
366 if signature_changed {
367 ChangeKind::SignatureChanged
368 } else {
369 ChangeKind::Modified
370 },
371 ),
372 };
373 symbols.push(ChangedSymbol {
374 file: change.path.clone(),
375 symbol: name,
376 change: kind,
377 });
378 }
379 }
380 (None, Some(new)) => {
381 for s in new.all_symbols() {
382 symbols.push(ChangedSymbol {
383 file: change.path.clone(),
384 symbol: s.name.clone(),
385 change: ChangeKind::Added,
386 });
387 }
388 }
389 (Some(old), None) => {
390 for s in old.all_symbols() {
391 symbols.push(ChangedSymbol {
392 file: change.path.clone(),
393 symbol: s.name.clone(),
394 change: ChangeKind::Removed,
395 });
396 }
397 }
398 (None, None) => {}
399 }
400 }
401 (symbols, unparsed)
402}
403
404pub fn containment_violations(
407 changed: &[ChangedSymbol],
408 footprint: &DeclaredFootprint,
409) -> Vec<ContainmentViolation> {
410 if !footprint.is_declared() {
411 return Vec::new();
412 }
413 changed
414 .iter()
415 .filter(|c| !footprint.allows(&c.as_ref()))
416 .map(|c| ContainmentViolation { changed: c.clone() })
417 .collect()
418}
419
420pub fn duplicate_declarations(changes: &[FileChange]) -> Vec<DuplicateDeclaration> {
425 let mut out = Vec::new();
426 for change in changes {
427 let Some(parsed) = change
428 .after
429 .as_deref()
430 .and_then(|src| parse_file(src, &change.path))
431 else {
432 continue;
433 };
434 let mut counts: HashMap<(String, SymbolKind), usize> = HashMap::new();
435 for sym in parsed.all_symbols() {
436 if matches!(sym.kind, SymbolKind::Import) {
438 continue;
439 }
440 *counts.entry((sym.name.clone(), sym.kind)).or_insert(0) += 1;
441 }
442 for ((name, kind), count) in counts {
443 if count > 1 {
444 out.push(DuplicateDeclaration {
445 file: change.path.clone(),
446 symbol: name,
447 kind: format!("{kind:?}"),
448 count,
449 });
450 }
451 }
452 }
453 out
454}
455
456pub fn decide(evidence: GateEvidence, waiver: Option<&NoVerifyWaiver>) -> MergeVerdict {
461 if let PolicyDecision::Deny { reasons } = &evidence.policy {
463 let reasons = reasons
464 .iter()
465 .map(|r| format!("policy denied integration: {r}"))
466 .collect();
467 return MergeVerdict::Rejected { reasons, evidence };
468 }
469
470 let mut reasons = Vec::new();
472 for v in &evidence.containment_violations {
473 reasons.push(format!(
474 "changed {}::{} outside declared footprint",
475 v.changed.file, v.changed.symbol
476 ));
477 }
478 for d in &evidence.semantic_conflicts {
479 reasons.push(format!(
480 "{} duplicate {} declarations of {} in {}",
481 d.count, d.kind, d.symbol, d.file
482 ));
483 }
484 if let BuildTestStatus::Failed { code, output } = &evidence.build_test {
485 reasons.push(format!(
486 "build/test failed (exit {code:?}): {}",
487 tail(output, 300)
488 ));
489 }
490 if !reasons.is_empty() {
491 return MergeVerdict::Rejected { reasons, evidence };
492 }
493
494 match &evidence.build_test {
497 BuildTestStatus::Passed => MergeVerdict::Accepted {
498 basis: AcceptanceBasis::Verified,
499 evidence,
500 },
501 BuildTestStatus::NotConfigured => match waiver {
502 Some(w) => MergeVerdict::Accepted {
503 basis: AcceptanceBasis::Waived {
504 class: w.class.clone(),
505 reason: w.reason.clone(),
506 },
507 evidence,
508 },
509 None => MergeVerdict::Inconclusive {
510 reasons: vec![
511 "build/test not configured and no waiver supplied — cannot affirm safety"
512 .to_string(),
513 ],
514 evidence,
515 },
516 },
517 BuildTestStatus::NotRun { reason } => MergeVerdict::Inconclusive {
518 reasons: vec![format!("build/test did not run: {reason}")],
519 evidence,
520 },
521 BuildTestStatus::Failed { .. } => MergeVerdict::Rejected {
523 reasons: vec!["build/test failed".to_string()],
524 evidence,
525 },
526 }
527}
528
529pub async fn verify_changes(
536 config: &GateConfig,
537 changes: &[FileChange],
538 footprint: &DeclaredFootprint,
539 infra: &SharedInfra,
540) -> MergeVerdict {
541 let (changed_symbols, unparsed_changed_files) = extract_changes(changes);
542 let containment_list = containment_violations(&changed_symbols, footprint);
543 let duplicates = duplicate_declarations(changes);
544
545 let containment = if !footprint.is_declared() {
546 CheckOutcome::NotRun
547 } else if containment_list.is_empty() {
548 CheckOutcome::Passed
549 } else {
550 CheckOutcome::Failed
551 };
552 let duplicate_outcome = if duplicates.is_empty() {
553 CheckOutcome::Passed
554 } else {
555 CheckOutcome::Failed
556 };
557
558 let policy = consult_policy(config, changes, infra).await;
559
560 let ast_failed = !containment_list.is_empty() || !duplicates.is_empty();
561 let build_test = if ast_failed {
562 BuildTestStatus::NotRun {
563 reason: "AST checks already failed".to_string(),
564 }
565 } else if let PolicyDecision::Deny { .. } = &policy {
566 BuildTestStatus::NotRun {
567 reason: "policy denied integration".to_string(),
568 }
569 } else {
570 run_verify_command(config).await
571 };
572
573 let evidence = GateEvidence {
574 subtask: config.subtask.clone(),
575 changed_symbols,
576 footprint_declared: footprint.is_declared(),
577 containment,
578 containment_violations: containment_list,
579 unparsed_changed_files,
580 duplicates: duplicate_outcome,
581 semantic_conflicts: duplicates,
582 build_test,
583 policy,
584 };
585
586 let verdict = decide(evidence, config.no_verify_waiver.as_ref());
587 emit_audit(&verdict, infra).await;
588 verdict
589}
590
591async fn consult_policy(
596 config: &GateConfig,
597 changes: &[FileChange],
598 infra: &SharedInfra,
599) -> PolicyDecision {
600 let files: Vec<Value> = changes
601 .iter()
602 .filter(|c| c.content_changed())
603 .map(|c| json!(c.path))
604 .collect();
605 let mut parameters = HashMap::new();
606 parameters.insert("subtask".to_string(), json!(config.subtask));
607 parameters.insert("files".to_string(), json!(files));
608
609 let action = {
610 let mut a = Action::new(ActionType::ToolCall);
611 a.id = format!("foreman-integrate-{}", config.subtask);
612 a.tool = Some("foreman.integrate".to_string());
613 a.parameters = parameters;
614 a.idempotent = true;
615 a.max_retries = 0;
616 a.failure_behavior = FailureBehavior::Skip;
617 a
618 };
619
620 let violations = infra.policies.read().await.check(&action, &infra.state);
621 if violations.is_empty() {
622 PolicyDecision::Allow
623 } else {
624 PolicyDecision::Deny {
625 reasons: violations
626 .into_iter()
627 .map(|v| format!("{}: {}", v.policy_name, v.reason))
628 .collect(),
629 }
630 }
631}
632
633async fn run_verify_command(config: &GateConfig) -> BuildTestStatus {
634 let Some(cmd) = &config.verify_command else {
635 return BuildTestStatus::NotConfigured;
636 };
637 let Some((program, args)) = cmd.split_first() else {
638 return BuildTestStatus::NotConfigured;
639 };
640
641 if !config.cwd.is_dir() {
644 return BuildTestStatus::NotRun {
645 reason: format!("verify cwd does not exist: {}", config.cwd.display()),
646 };
647 }
648
649 let mut cmd = car_engine::spawn::program_command(program);
652 cmd.args(args).current_dir(&config.cwd);
653 let run = car_registry::proc::output_with_tree_kill(cmd);
658
659 let output = match tokio::time::timeout(config.verify_timeout, run).await {
662 Ok(res) => res,
663 Err(_) => {
664 return BuildTestStatus::NotRun {
665 reason: format!("verify command timed out after {:?}", config.verify_timeout),
666 };
667 }
668 };
669
670 match output {
671 Ok(out) if out.status.success() => BuildTestStatus::Passed,
672 Ok(out) => {
673 let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
674 combined.push_str(&String::from_utf8_lossy(&out.stderr));
675 BuildTestStatus::Failed {
676 code: out.status.code(),
677 output: tail(&combined, config.max_output_bytes),
678 }
679 }
680 Err(e) => BuildTestStatus::Failed {
681 code: None,
682 output: format!("failed to launch verify command: {e}"),
683 },
684 }
685}
686
687async fn emit_audit(verdict: &MergeVerdict, infra: &SharedInfra) {
688 let evidence = verdict.evidence();
689 let (outcome, basis, reasons) = match verdict {
690 MergeVerdict::Accepted { basis, .. } => {
691 let basis_str = match basis {
692 AcceptanceBasis::Verified => "verified".to_string(),
693 AcceptanceBasis::Waived { class, .. } => format!("waived:{class}"),
694 };
695 ("accepted", Some(basis_str), Vec::new())
696 }
697 MergeVerdict::Rejected { reasons, .. } => ("rejected", None, reasons.clone()),
698 MergeVerdict::Inconclusive { reasons, .. } => ("inconclusive", None, reasons.clone()),
699 };
700
701 let mut data = HashMap::new();
702 data.insert("subtask".to_string(), json!(evidence.subtask));
703 if let Some(scope) = &infra.gate_audit_scope {
704 data.insert("gate_audit_scope".to_string(), json!(scope));
705 }
706 data.insert("outcome".to_string(), json!(outcome));
707 if let Some(basis) = basis {
708 data.insert("basis".to_string(), json!(basis));
709 }
710 data.insert(
711 "changed_symbols".to_string(),
712 json!(evidence.changed_symbols.len()),
713 );
714 data.insert(
715 "containment_violations".to_string(),
716 json!(evidence.containment_violations.len()),
717 );
718 data.insert(
719 "unparsed_changed_files".to_string(),
720 json!(evidence.unparsed_changed_files),
721 );
722 data.insert(
723 "semantic_conflicts".to_string(),
724 json!(evidence.semantic_conflicts.len()),
725 );
726 data.insert(
727 "build_test".to_string(),
728 json!(match &evidence.build_test {
729 BuildTestStatus::NotConfigured => "not_configured",
730 BuildTestStatus::NotRun { .. } => "not_run",
731 BuildTestStatus::Passed => "passed",
732 BuildTestStatus::Failed { .. } => "failed",
733 }),
734 );
735 if !reasons.is_empty() {
736 data.insert("reasons".to_string(), json!(reasons));
737 }
738
739 infra
740 .log
741 .lock()
742 .await
743 .append(verdict.audit_kind(), None, None, data);
744}
745
746fn tail(s: &str, max_bytes: usize) -> String {
749 if s.len() <= max_bytes {
750 return s.to_string();
751 }
752 let mut start = s.len() - max_bytes;
753 while start < s.len() && !s.is_char_boundary(start) {
754 start += 1;
755 }
756 format!("…[truncated]\n{}", &s[start..])
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 fn rs(path: &str, before: Option<&str>, after: Option<&str>) -> FileChange {
764 FileChange {
765 path: path.to_string(),
766 before: before.map(str::to_string),
767 after: after.map(str::to_string),
768 }
769 }
770
771 #[test]
774 fn detects_modified_and_added_symbols() {
775 let (changed, unparsed) = extract_changes(&[rs(
776 "src/lib.rs",
777 Some("pub fn alpha() {}\n"),
778 Some("pub fn alpha() -> u8 { 1 }\npub fn beta() {}\n"),
779 )]);
780 assert!(unparsed.is_empty());
781 let names: Vec<_> = changed.iter().map(|c| c.symbol.as_str()).collect();
782 assert!(names.contains(&"alpha"), "alpha changed: {names:?}");
783 assert!(names.contains(&"beta"), "beta added: {names:?}");
784 }
785
786 #[test]
787 fn unparseable_changed_file_is_recorded() {
788 let (changed, unparsed) = extract_changes(&[rs(
790 "Cargo.toml",
791 Some("[package]\n"),
792 Some("[package]\nx=1\n"),
793 )]);
794 assert!(changed.is_empty(), "no symbols from an unparseable file");
795 assert_eq!(unparsed, vec!["Cargo.toml".to_string()]);
796 }
797
798 #[test]
799 fn containment_flags_out_of_footprint_edits() {
800 let (changed, _) = extract_changes(&[rs(
801 "src/lib.rs",
802 Some("pub fn allowed() {}\npub fn sneaky() {}\n"),
803 Some("pub fn allowed() -> u8 { 1 }\npub fn sneaky() -> u8 { 2 }\n"),
804 )]);
805 let footprint = DeclaredFootprint::from_refs([SymbolRef::new("src/lib.rs", "allowed")]);
806 let violations = containment_violations(&changed, &footprint);
807 assert_eq!(violations.len(), 1);
808 assert_eq!(violations[0].changed.symbol, "sneaky");
809 }
810
811 #[test]
812 fn duplicate_declarations_catches_method_level() {
813 let after =
816 "pub struct S;\nimpl S {\n pub fn handle(&self) {}\n pub fn handle(&self) {}\n}\n";
817 let dups =
818 duplicate_declarations(&[rs("src/lib.rs", Some("pub struct S;\n"), Some(after))]);
819 assert!(
820 dups.iter().any(|d| d.symbol == "handle" && d.count == 2),
821 "method-level duplicate must be caught: {dups:?}"
822 );
823 }
824
825 fn clean_evidence(build_test: BuildTestStatus) -> GateEvidence {
828 GateEvidence {
829 subtask: "t".to_string(),
830 changed_symbols: vec![],
831 footprint_declared: false,
832 containment: CheckOutcome::NotRun,
833 containment_violations: vec![],
834 unparsed_changed_files: vec![],
835 duplicates: CheckOutcome::Passed,
836 semantic_conflicts: vec![],
837 build_test,
838 policy: PolicyDecision::Allow,
839 }
840 }
841
842 #[test]
843 fn skipped_build_test_is_inconclusive_not_accepted() {
844 let verdict = decide(clean_evidence(BuildTestStatus::NotConfigured), None);
846 assert!(
847 matches!(verdict, MergeVerdict::Inconclusive { .. }),
848 "unconfigured build/test must be inconclusive, got {verdict:?}"
849 );
850 assert!(!verdict.is_accepted());
851 }
852
853 #[test]
854 fn passed_build_test_yields_verified_acceptance() {
855 let verdict = decide(clean_evidence(BuildTestStatus::Passed), None);
856 assert!(verdict.is_verified());
857 }
858
859 #[test]
860 fn explicit_waiver_accepts_without_build_test_but_not_verified() {
861 let waiver = NoVerifyWaiver {
862 class: "docs-only".to_string(),
863 reason: "README change".to_string(),
864 };
865 let verdict = decide(
866 clean_evidence(BuildTestStatus::NotConfigured),
867 Some(&waiver),
868 );
869 assert!(verdict.is_accepted(), "explicit waiver accepts");
870 assert!(!verdict.is_verified(), "but it is NOT build/test-verified");
871 }
872
873 #[test]
874 fn failed_build_test_rejects() {
875 let verdict = decide(
876 clean_evidence(BuildTestStatus::Failed {
877 code: Some(101),
878 output: "boom".to_string(),
879 }),
880 None,
881 );
882 assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
883 }
884
885 #[test]
886 fn policy_denial_rejects_even_with_passing_build() {
887 let mut ev = clean_evidence(BuildTestStatus::Passed);
888 ev.policy = PolicyDecision::Deny {
889 reasons: vec!["protected path".to_string()],
890 };
891 let verdict = decide(ev, None);
892 assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
893 }
894
895 #[test]
896 fn containment_violation_rejects_even_with_passing_build() {
897 let mut ev = clean_evidence(BuildTestStatus::Passed);
898 ev.containment_violations = vec![ContainmentViolation {
899 changed: ChangedSymbol {
900 file: "src/lib.rs".to_string(),
901 symbol: "sneaky".to_string(),
902 change: ChangeKind::Modified,
903 },
904 }];
905 assert!(matches!(decide(ev, None), MergeVerdict::Rejected { .. }));
906 }
907
908 #[tokio::test]
911 async fn verify_accepts_clean_change_with_passing_command_and_audits() {
912 let infra = SharedInfra::new();
913 let change = rs(
914 "src/lib.rs",
915 Some("pub fn a() {}\n"),
916 Some("pub fn a() -> u8 { 1 }\n"),
917 );
918 let config = GateConfig::new("subtask-1", std::env::temp_dir())
919 .with_verify_command(crate::patterns::foreman::test_verify::pass());
920 let verdict = verify_changes(
921 &config,
922 &[change],
923 &DeclaredFootprint::unconstrained(),
924 &infra,
925 )
926 .await;
927 assert!(
928 verdict.is_verified(),
929 "clean change + passing build = verified"
930 );
931 let log = infra.log.lock().await;
932 assert_eq!(log.events()[0].kind, EventKind::GateAccepted);
933 }
934
935 #[tokio::test]
936 async fn verify_without_command_is_inconclusive() {
937 let infra = SharedInfra::new();
938 let change = rs(
939 "src/lib.rs",
940 Some("pub fn a() {}\n"),
941 Some("pub fn a() -> u8 { 1 }\n"),
942 );
943 let config = GateConfig::new("subtask-2", std::env::temp_dir());
945 let verdict = verify_changes(
946 &config,
947 &[change],
948 &DeclaredFootprint::unconstrained(),
949 &infra,
950 )
951 .await;
952 assert!(!verdict.is_accepted());
953 assert!(matches!(verdict, MergeVerdict::Inconclusive { .. }));
954 let log = infra.log.lock().await;
955 assert_eq!(log.events()[0].kind, EventKind::GateRejected);
956 }
957
958 #[tokio::test]
959 async fn verify_rejects_containment_escape_and_skips_build() {
960 let infra = SharedInfra::new();
961 let change = rs(
962 "src/lib.rs",
963 Some("pub fn allowed() {}\npub fn sneaky() {}\n"),
964 Some("pub fn allowed() {}\npub fn sneaky() -> u8 { 2 }\n"),
965 );
966 let footprint = DeclaredFootprint::from_refs([SymbolRef::new("src/lib.rs", "allowed")]);
967 let config = GateConfig::new("subtask-3", std::env::temp_dir())
968 .with_verify_command(crate::patterns::foreman::test_verify::pass());
969 let verdict = verify_changes(&config, &[change], &footprint, &infra).await;
970 assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
971 assert!(matches!(
973 verdict.evidence().build_test,
974 BuildTestStatus::NotRun { .. }
975 ));
976 }
977
978 #[tokio::test]
979 async fn policy_can_deny_integration() {
980 let infra = SharedInfra::new();
981 infra.policies.write().await.register(
983 "protect-cargo-lock",
984 Box::new(|action: &Action, _| {
985 let touches = action
986 .parameters
987 .get("files")
988 .and_then(|f| f.as_array())
989 .map(|arr| arr.iter().any(|v| v.as_str() == Some("Cargo.lock")))
990 .unwrap_or(false);
991 if touches {
992 Some("integration touches protected Cargo.lock".to_string())
993 } else {
994 None
995 }
996 }),
997 "block merges touching Cargo.lock",
998 );
999 let change = rs("Cargo.lock", Some("a = 1\n"), Some("a = 2\n"));
1000 let config = GateConfig::new("subtask-4", std::env::temp_dir())
1001 .with_verify_command(crate::patterns::foreman::test_verify::pass());
1002 let verdict = verify_changes(
1003 &config,
1004 &[change],
1005 &DeclaredFootprint::unconstrained(),
1006 &infra,
1007 )
1008 .await;
1009 assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
1010 }
1011
1012 #[tokio::test]
1013 async fn missing_verify_cwd_is_inconclusive_not_accepted() {
1014 let infra = SharedInfra::new();
1015 let change = rs(
1016 "src/lib.rs",
1017 Some("pub fn a() {}\n"),
1018 Some("pub fn a() -> u8 { 1 }\n"),
1019 );
1020 let config = GateConfig::new("subtask-5", "/nonexistent/foreman/tree")
1023 .with_verify_command(crate::patterns::foreman::test_verify::pass());
1024 let verdict = verify_changes(
1025 &config,
1026 &[change],
1027 &DeclaredFootprint::unconstrained(),
1028 &infra,
1029 )
1030 .await;
1031 assert!(!verdict.is_accepted());
1032 assert!(matches!(verdict, MergeVerdict::Inconclusive { .. }));
1033 }
1034}