agent_doc_element_backlog/
ops_proof.rs1use std::collections::HashSet;
7
8use agent_doc_element::element;
9
10use crate::backlog::{self, PendingItem, PendingState};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct OpsProofCompletion {
14 pub id: String,
15 pub evidence: String,
16}
17
18pub fn surface_pending_ids(content: &str, surface: &str) -> HashSet<String> {
23 element::parse(content)
24 .ok()
25 .and_then(|comps| {
26 comps
27 .into_iter()
28 .find(|c| backlog::component_matches_tracked_surface(&c.name, surface))
29 })
30 .map(|comp| {
31 let (_, items, _) = backlog::parse_items(comp.content(content));
32 items
33 .into_iter()
34 .map(|item| item.id)
35 .filter(|id| !id.is_empty())
36 .collect()
37 })
38 .unwrap_or_default()
39}
40
41pub fn ops_proof_completion_candidates(body: &str) -> Vec<OpsProofCompletion> {
42 let (_, items, _) = backlog::parse_items(body);
43 items
44 .iter()
45 .filter(|item| !matches!(item.state, PendingState::Done))
46 .filter_map(|item| {
47 classify_ops_proof_completion(item).map(|evidence| OpsProofCompletion {
48 id: item.id.clone(),
49 evidence,
50 })
51 })
52 .collect()
53}
54
55pub fn classify_ops_proof_completion(item: &PendingItem) -> Option<String> {
56 if item.id.is_empty() {
57 return None;
58 }
59 let text = format!("{} {}", item.text, item.continuation);
60 let upper = text.to_ascii_uppercase();
61 if !has_ops_completion_marker(&upper) || has_ops_completion_blocker(&upper) {
62 return None;
63 }
64
65 if is_live_verify_gate(&upper) {
71 return None;
72 }
73
74 let is_gated = matches!(item.state, PendingState::Gated);
80 if !is_gated && !marker_is_leading_status(&upper) {
81 return None;
82 }
83
84 let has_commit = contains_commit_hash(&text);
85 let has_ci = contains_successful_ci_proof(&upper);
86 if !has_commit && !has_ci {
87 return None;
88 }
89
90 Some(
91 match (has_commit, has_ci) {
92 (true, true) => "commit+ci",
93 (true, false) => "commit",
94 (false, true) => "ci",
95 (false, false) => unreachable!(),
96 }
97 .to_string(),
98 )
99}
100
101pub fn has_ops_completion_marker(upper: &str) -> bool {
102 ["DONE", "SHIPPED", "IMPLEMENTED", "COMPLETE", "COMPLETED"]
103 .iter()
104 .any(|marker| contains_ascii_word(upper, marker))
105}
106
107pub const LEADING_STATUS_WORDS: usize = 4;
110
111pub fn marker_is_leading_status(upper: &str) -> bool {
117 has_ops_completion_marker(&leading_status_segment(upper))
118}
119
120pub fn leading_status_segment(upper: &str) -> String {
121 let mut cut = upper.len();
122 for sep in [": ", ". "] {
123 if let Some(idx) = upper.find(sep) {
124 cut = cut.min(idx);
125 }
126 }
127 upper[..cut]
128 .split_whitespace()
129 .filter(|word| !word.starts_with('#'))
130 .take(LEADING_STATUS_WORDS)
131 .collect::<Vec<_>>()
132 .join(" ")
133}
134
135pub fn has_ops_completion_blocker(upper: &str) -> bool {
136 const BLOCKER_PHRASES: &[&str] = &[
137 "COULD NOT",
138 "CAN NOT",
139 "CANNOT",
140 "CAN'T",
141 "FALSE CLOSEOUT",
142 "FOLLOW-UP",
143 "FOLLOW UP",
144 "FOLLOWUPS",
145 "NOT DONE",
146 "NOT SHIPPED",
147 "NOT IMPLEMENTED",
148 "SUB-PART",
149 "SUBPART",
150 ];
151 const BLOCKER_WORDS: &[&str] = &[
152 "PARTIAL",
153 "REMAINING",
154 "REOPENED",
155 "DEFERRED",
156 "BLOCKED",
157 "BLOCKER",
158 "TODO",
159 "WIP",
160 "PARTLY",
161 "FAILING",
162 "FAILED",
163 ];
164
165 BLOCKER_PHRASES.iter().any(|phrase| upper.contains(phrase))
166 || BLOCKER_WORDS
167 .iter()
168 .any(|word| contains_ascii_word(upper, word))
169}
170
171pub fn is_live_verify_gate(upper: &str) -> bool {
176 const LIVE_VERIFY_PHRASES: &[&str] = &[
177 "LIVE-VERIFY GATE",
178 "LIVE-VERIFY ONLY",
179 "LIVE VERIFY GATE",
180 "LIVE VERIFY ONLY",
181 "OPERATOR-DRIVE",
182 "OPERATOR DRIVE",
183 "OPERATOR DRIVES",
184 "OPERATOR LIVE-VERIFY",
185 "OPERATOR LIVE VERIFY",
186 ];
187 LIVE_VERIFY_PHRASES
188 .iter()
189 .any(|phrase| upper.contains(phrase))
190}
191
192pub fn contains_successful_ci_proof(upper: &str) -> bool {
193 contains_ascii_word(upper, "CI")
194 && ["GREEN", "PASSED", "PASSING", "SUCCESS", "SUCCEEDED"]
195 .iter()
196 .any(|word| contains_ascii_word(upper, word))
197}
198
199pub fn contains_commit_hash(text: &str) -> bool {
200 text.split(|c: char| !c.is_ascii_alphanumeric())
201 .any(|token| {
202 (7..=40).contains(&token.len())
203 && token.chars().all(|c| c.is_ascii_hexdigit())
204 && token.chars().any(|c| matches!(c, 'a'..='f' | 'A'..='F'))
205 })
206}
207
208pub fn contains_ascii_word(haystack: &str, needle: &str) -> bool {
209 haystack.match_indices(needle).any(|(idx, _)| {
210 let before = idx
211 .checked_sub(1)
212 .and_then(|pos| haystack.as_bytes().get(pos).copied());
213 let after = haystack.as_bytes().get(idx + needle.len()).copied();
214 before.is_none_or(|b| !b.is_ascii_alphanumeric())
215 && after.is_none_or(|b| !b.is_ascii_alphanumeric())
216 })
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn item(id: &str, state: PendingState, text: &str) -> PendingItem {
224 PendingItem {
225 marker: backlog::PendingListMarker::Bullet,
226 id: id.to_string(),
227 state,
228 gate_type: None,
229 in_progress: false,
230 text: text.to_string(),
231 continuation: String::new(),
232 }
233 }
234
235 #[test]
236 fn classifies_leading_status_with_commit_and_ci() {
237 let result = classify_ops_proof_completion(&item(
238 "doneci",
239 PendingState::Open,
240 "#agent-doc-bug DONE 7b60fcdc (CI 27075841879 green): fixed it",
241 ));
242 assert_eq!(result.as_deref(), Some("commit+ci"));
243 }
244
245 #[test]
246 fn rejects_blocked_partial_and_missing_proof_items() {
247 assert_eq!(
248 classify_ops_proof_completion(&item(
249 "partial",
250 PendingState::Open,
251 "PARTIAL SHIPPED 9df1244f: REMAINING live proof gate",
252 )),
253 None
254 );
255 assert_eq!(
256 classify_ops_proof_completion(&item(
257 "noproof",
258 PendingState::Open,
259 "DONE: lacks deterministic proof",
260 )),
261 None
262 );
263 }
264
265 #[test]
266 fn rejects_cited_dependency_marker_for_open_items() {
267 let result = classify_ops_proof_completion(&item(
268 "citeddep",
269 PendingState::Open,
270 "wire the predicate into dispatch. The predicate already shipped in 600797b3",
271 ));
272 assert_eq!(result, None);
273 }
274
275 #[test]
276 fn accepts_gated_completion_marker_anywhere() {
277 let result = classify_ops_proof_completion(&item(
278 "reviewdone",
279 PendingState::Gated,
280 "review-gated path SHIPPED abcdef1 (CI 2 passed)",
281 ));
282 assert_eq!(result.as_deref(), Some("commit+ci"));
283 }
284
285 #[test]
286 fn rejects_live_verify_gate_on_commit_hash() {
287 let result = classify_ops_proof_completion(&item(
288 "ktw8",
289 PendingState::Gated,
290 "[live-verify gate] destructive path. Code SHIPPED 1edb20d2; operator drives proof.",
291 ));
292 assert_eq!(result, None);
293 }
294
295 #[test]
296 fn surface_pending_ids_reads_backlog_aliases_and_review_surface() {
297 let content = concat!(
298 "<!-- agent:pending -->\n",
299 "- [ ] [#a] one\n",
300 "<!-- /agent:pending -->\n\n",
301 "<!-- agent:review -->\n",
302 "- [/] [#r] review\n",
303 "<!-- /agent:review -->\n",
304 );
305 assert_eq!(
306 surface_pending_ids(content, "backlog"),
307 HashSet::from(["a".to_string()])
308 );
309 assert_eq!(
310 surface_pending_ids(content, "review"),
311 HashSet::from(["r".to_string()])
312 );
313 }
314}