libverify_core/controls/
merge_commit_policy.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, GovernedChange};
3
4pub struct MergeCommitPolicyControl;
11
12impl Control for MergeCommitPolicyControl {
13 fn id(&self) -> ControlId {
14 builtin::id(builtin::MERGE_COMMIT_POLICY)
15 }
16
17 fn description(&self) -> &'static str {
18 "Source revisions must follow linear history (no merge commits)"
19 }
20
21 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
22 if evidence.change_requests.is_empty() {
23 return vec![ControlFinding::not_applicable(
24 self.id(),
25 "No change requests found",
26 )];
27 }
28
29 evidence
30 .change_requests
31 .iter()
32 .map(|cr| evaluate_change(self.id(), cr))
33 .collect()
34 }
35}
36
37fn evaluate_change(id: ControlId, cr: &GovernedChange) -> ControlFinding {
38 let cr_subject = cr.id.to_string();
39
40 let revisions = match &cr.source_revisions {
41 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
42 EvidenceState::Missing { gaps } => {
43 return ControlFinding::indeterminate(
44 id,
45 format!("{cr_subject}: source revision evidence could not be collected"),
46 vec![cr_subject],
47 gaps.clone(),
48 );
49 }
50 EvidenceState::NotApplicable => {
51 return ControlFinding::not_applicable(id, "Source revisions not applicable");
52 }
53 };
54
55 if revisions.is_empty() {
56 return ControlFinding::not_applicable(
57 id,
58 format!("{cr_subject}: no source revisions to evaluate"),
59 );
60 }
61
62 let merge_commits: Vec<&str> = revisions
63 .iter()
64 .filter(|r| r.merge)
65 .map(|r| r.id.as_str())
66 .collect();
67
68 if merge_commits.is_empty() {
69 ControlFinding::satisfied(
70 id,
71 format!(
72 "{cr_subject}: all {} revision(s) follow linear history",
73 revisions.len()
74 ),
75 vec![cr_subject],
76 )
77 } else {
78 ControlFinding::violated(
79 id,
80 format!(
81 "{cr_subject}: {} merge commit(s) found: {}",
82 merge_commits.len(),
83 merge_commits.join(", ")
84 ),
85 merge_commits.into_iter().map(String::from).collect(),
86 )
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::control::ControlStatus;
94 use crate::evidence::{ChangeRequestId, SourceRevision};
95
96 fn revision(id: &str, merge: bool) -> SourceRevision {
97 SourceRevision {
98 id: id.to_string(),
99 authored_by: Some("dev".to_string()),
100 committed_at: None,
101 merge,
102 authenticity: EvidenceState::not_applicable(),
103 }
104 }
105
106 fn make_change(revisions: EvidenceState<Vec<SourceRevision>>) -> GovernedChange {
107 GovernedChange {
108 id: ChangeRequestId::new("test", "owner/repo#1"),
109 title: "test".to_string(),
110 summary: None,
111 submitted_by: None,
112 changed_assets: EvidenceState::not_applicable(),
113 approval_decisions: EvidenceState::not_applicable(),
114 source_revisions: revisions,
115 work_item_refs: EvidenceState::not_applicable(),
116 }
117 }
118
119 fn bundle(changes: Vec<GovernedChange>) -> EvidenceBundle {
120 EvidenceBundle {
121 change_requests: changes,
122 ..Default::default()
123 }
124 }
125
126 #[test]
127 fn not_applicable_when_no_changes() {
128 let findings = MergeCommitPolicyControl.evaluate(&EvidenceBundle::default());
129 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
130 }
131
132 #[test]
133 fn satisfied_when_all_linear() {
134 let cr = make_change(EvidenceState::complete(vec![
135 revision("abc", false),
136 revision("def", false),
137 ]));
138 let findings = MergeCommitPolicyControl.evaluate(&bundle(vec![cr]));
139 assert_eq!(findings[0].status, ControlStatus::Satisfied);
140 }
141
142 #[test]
143 fn violated_when_merge_commit_present() {
144 let cr = make_change(EvidenceState::complete(vec![
145 revision("abc", false),
146 revision("merge123", true),
147 ]));
148 let findings = MergeCommitPolicyControl.evaluate(&bundle(vec![cr]));
149 assert_eq!(findings[0].status, ControlStatus::Violated);
150 assert!(findings[0].rationale.contains("merge123"));
151 }
152
153 #[test]
154 fn not_applicable_when_no_revisions() {
155 let cr = make_change(EvidenceState::complete(vec![]));
156 let findings = MergeCommitPolicyControl.evaluate(&bundle(vec![cr]));
157 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
158 }
159
160 #[test]
161 fn indeterminate_when_revisions_missing() {
162 let cr = make_change(EvidenceState::missing(vec![
163 crate::evidence::EvidenceGap::CollectionFailed {
164 source: "github".to_string(),
165 subject: "commits".to_string(),
166 detail: "API error".to_string(),
167 },
168 ]));
169 let findings = MergeCommitPolicyControl.evaluate(&bundle(vec![cr]));
170 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
171 }
172}