1use crate::scratchpad::config::ScratchpadConfig;
4use crate::scratchpad::schema::{AreaStatus, Inventory, NoteLine};
5use crate::scratchpad::summary::compute_superseded_ids;
6
7#[derive(Debug, Clone)]
9pub struct CoverageStats {
10 pub areas_total: usize,
11 pub areas_accounted: usize,
12 pub areas_reviewed: usize,
13 pub accounted_ratio: f64,
14 pub reviewed_ratio: f64,
15 pub pending_area_ids: Vec<String>,
16 pub deferred_areas: Vec<DeferredAreaSummary>,
17 pub verified_findings: usize,
18}
19
20#[derive(Debug, Clone)]
21pub struct DeferredAreaSummary {
22 pub id: String,
23 pub reason_excerpt: String,
24}
25
26#[derive(Debug, Clone)]
28pub struct AreaQualityGap {
29 pub id: String,
30 pub status: String,
31 pub fix: String,
32}
33
34#[derive(Debug, Clone)]
35pub enum CoverageGateOutcome {
36 Allow {
37 stats: CoverageStats,
38 },
39 Warn {
40 stats: CoverageStats,
41 warning_text: String,
42 },
43 Block {
44 stats: CoverageStats,
45 reason: String,
46 },
47}
48
49#[must_use]
51pub fn area_meets_done_quality(area_id: &str, notes: &[NoteLine]) -> bool {
52 notes
53 .iter()
54 .any(|n| n.area_id == area_id && (n.kind == "finding" || n.kind == "cleared"))
55}
56
57#[must_use]
59pub fn area_meets_deferred_quality(area_id: &str, notes: &[NoteLine]) -> bool {
60 notes.iter().any(|n| {
61 n.area_id == area_id
62 && n.kind == "meta"
63 && n.claim.as_ref().is_some_and(|c| !c.trim().is_empty())
64 })
65}
66
67#[must_use]
68pub fn compute_coverage_stats(
69 inventory: &Inventory,
70 notes: &[NoteLine],
71 config: &ScratchpadConfig,
72) -> CoverageStats {
73 let superseded = compute_superseded_ids(notes);
74 let verified_findings = notes
75 .iter()
76 .filter(|n| {
77 n.kind == "finding"
78 && n.status.eq_ignore_ascii_case("verified")
79 && !superseded.contains(&n.id)
80 })
81 .count();
82
83 let areas_total = inventory.areas.len();
84 let mut areas_accounted = 0usize;
85 let mut areas_reviewed = 0usize;
86 let mut pending_area_ids = Vec::new();
87 let mut deferred_areas = Vec::new();
88
89 for area in &inventory.areas {
90 match area.status {
91 AreaStatus::Pending | AreaStatus::InProgress => {
92 pending_area_ids.push(area.id.clone());
93 }
94 AreaStatus::Done => {
95 if area_meets_done_quality(&area.id, notes) {
96 areas_accounted += 1;
97 areas_reviewed += 1;
98 }
99 }
100 AreaStatus::Deferred => {
101 if config.coverage_count_deferred_as_accounted
102 && area_meets_deferred_quality(&area.id, notes)
103 {
104 areas_accounted += 1;
105 if let Some(reason) = deferred_reason_excerpt(&area.id, notes) {
106 deferred_areas.push(DeferredAreaSummary {
107 id: area.id.clone(),
108 reason_excerpt: reason,
109 });
110 }
111 }
112 }
113 }
114 }
115
116 let accounted_ratio = ratio(areas_accounted, areas_total);
117 let reviewed_ratio = ratio(areas_reviewed, areas_total);
118
119 CoverageStats {
120 areas_total,
121 areas_accounted,
122 areas_reviewed,
123 accounted_ratio,
124 reviewed_ratio,
125 pending_area_ids,
126 deferred_areas,
127 verified_findings,
128 }
129}
130
131fn ratio(num: usize, den: usize) -> f64 {
132 if den == 0 {
133 1.0
134 } else {
135 num as f64 / den as f64
136 }
137}
138
139fn deferred_reason_excerpt(area_id: &str, notes: &[NoteLine]) -> Option<String> {
140 notes
141 .iter()
142 .filter(|n| n.area_id == area_id && n.kind == "meta")
143 .find_map(|n| n.claim.as_ref().filter(|c| !c.trim().is_empty()))
144 .map(|c| {
145 let t = c.trim();
146 if t.chars().count() > 120 {
147 let head: String = t.chars().take(120).collect();
148 format!("{head}…")
149 } else {
150 t.to_string()
151 }
152 })
153}
154
155#[must_use]
157pub fn build_l0_status_line(run_id: &str, stats: &CoverageStats, resume_area_id: &str) -> String {
158 let accounted_pct = (stats.accounted_ratio * 100.0).round() as u32;
159 format!(
160 "run_id={run_id} areas {}/{} accounted ({}%), {} reviewed; resume_area_id={}; verified_findings={}",
161 stats.areas_accounted,
162 stats.areas_total,
163 accounted_pct,
164 stats.areas_reviewed,
165 resume_area_id,
166 stats.verified_findings,
167 )
168}
169
170#[must_use]
171pub fn resume_area_id_from_inventory(inventory: &Inventory) -> String {
172 inventory
173 .areas
174 .iter()
175 .find(|a| matches!(a.status, AreaStatus::Pending | AreaStatus::InProgress))
176 .map(|a| a.id.as_str())
177 .unwrap_or("none")
178 .to_string()
179}
180
181#[must_use]
183pub fn areas_failing_quality_gate(
184 inventory: &Inventory,
185 notes: &[NoteLine],
186 config: &ScratchpadConfig,
187) -> Vec<AreaQualityGap> {
188 let mut gaps = Vec::new();
189 for area in &inventory.areas {
190 match area.status {
191 AreaStatus::Done if !area_meets_done_quality(&area.id, notes) => {
192 gaps.push(AreaQualityGap {
193 id: area.id.clone(),
194 status: "done".into(),
195 fix: "scratchpad_append kind=finding or kind=cleared (meta-only notes do not count for done)".into(),
196 });
197 }
198 AreaStatus::Deferred
199 if config.coverage_count_deferred_as_accounted
200 && !area_meets_deferred_quality(&area.id, notes) =>
201 {
202 gaps.push(AreaQualityGap {
203 id: area.id.clone(),
204 status: "deferred".into(),
205 fix: "scratchpad_append kind=meta with non-empty claim (defer reason)".into(),
206 });
207 }
208 _ => {}
209 }
210 }
211 gaps
212}
213
214#[must_use]
215pub fn format_quality_gate_block_reason(
216 stats: &CoverageStats,
217 gaps: &[AreaQualityGap],
218 config: &ScratchpadConfig,
219) -> String {
220 let mut reason = format!(
221 "accounted_ratio {:.0}% is below hard threshold {:.0}% ({} of {} areas meet quality gates)",
222 stats.accounted_ratio * 100.0,
223 config.coverage_hard_ratio * 100.0,
224 stats.areas_accounted,
225 stats.areas_total,
226 );
227 if !gaps.is_empty() {
228 reason.push_str("; areas failing quality gates:");
229 for gap in gaps.iter().take(12) {
230 reason.push_str(&format!("\n- {} ({}) — {}", gap.id, gap.status, gap.fix));
231 }
232 if gaps.len() > 12 {
233 reason.push_str(&format!("\n- … and {} more", gaps.len() - 12));
234 }
235 } else if !stats.pending_area_ids.is_empty() {
236 reason.push_str("; finish pending areas or mark deferred with kind=meta reason");
237 } else {
238 reason.push_str(
239 "; for each done area without findings use kind=cleared, not meta-only summaries",
240 );
241 }
242 reason
243}
244
245#[must_use]
246pub fn coverage_gate(
247 inventory: &Inventory,
248 notes: &[NoteLine],
249 config: &ScratchpadConfig,
250) -> CoverageGateOutcome {
251 let stats = compute_coverage_stats(inventory, notes, config);
252
253 if stats.areas_total == 0 {
254 return CoverageGateOutcome::Allow { stats };
255 }
256
257 if stats.accounted_ratio < config.coverage_hard_ratio && config.coverage_hard_block_enabled {
258 let gaps = areas_failing_quality_gate(inventory, notes, config);
259 let reason = format_quality_gate_block_reason(&stats, &gaps, config);
260 return CoverageGateOutcome::Block { stats, reason };
261 }
262
263 if stats.accounted_ratio < config.coverage_soft_ratio {
264 let pending = stats.pending_area_ids.join(", ");
265 let warning = format!(
266 "WARNING: {} area(s) pending — continue review or scratchpad_set_area(deferred) with kind=meta reason.\n\
267 pending_area_ids: [{pending}]",
268 stats.pending_area_ids.len(),
269 );
270 return CoverageGateOutcome::Warn {
271 stats,
272 warning_text: warning,
273 };
274 }
275
276 CoverageGateOutcome::Allow { stats }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::scratchpad::schema::{InventoryArea, parse_note_line};
283 use serde_json::json;
284
285 fn inv_with_areas(areas: Vec<InventoryArea>) -> Inventory {
286 Inventory {
287 run_id: "r".into(),
288 created_at: String::new(),
289 completed_at: None,
290 scope: None,
291 areas,
292 }
293 }
294
295 #[test]
296 fn empty_deferred_without_meta_not_accounted() {
297 let inv = inv_with_areas(vec![
298 InventoryArea {
299 id: "a1".into(),
300 path: "p".into(),
301 status: AreaStatus::Deferred,
302 notes: String::new(),
303 },
304 InventoryArea {
305 id: "a2".into(),
306 path: "p".into(),
307 status: AreaStatus::Done,
308 notes: String::new(),
309 },
310 ]);
311 let notes = vec![parse_note_line(
312 &json!({"id":"n1","area_id":"a2","kind":"finding","status":"verified"}),
313 1,
314 )];
315 let stats = compute_coverage_stats(&inv, ¬es, &ScratchpadConfig::default());
316 assert_eq!(stats.areas_accounted, 1);
317 assert!((stats.accounted_ratio - 0.5).abs() < f64::EPSILON);
318 }
319
320 #[test]
321 fn coverage_gate_blocks_low_accounted() {
322 let inv = inv_with_areas(vec![
323 InventoryArea {
324 id: "a1".into(),
325 path: "p".into(),
326 status: AreaStatus::Pending,
327 notes: String::new(),
328 },
329 InventoryArea {
330 id: "a2".into(),
331 path: "p".into(),
332 status: AreaStatus::Pending,
333 notes: String::new(),
334 },
335 ]);
336 let cfg = ScratchpadConfig {
337 coverage_hard_ratio: 0.6,
338 coverage_soft_ratio: 0.85,
339 ..Default::default()
340 };
341 let outcome = coverage_gate(&inv, &[], &cfg);
342 assert!(matches!(outcome, CoverageGateOutcome::Block { .. }));
343 }
344
345 #[test]
346 fn deferred_with_meta_counts_accounted() {
347 let inv = inv_with_areas(vec![InventoryArea {
348 id: "a1".into(),
349 path: "p".into(),
350 status: AreaStatus::Deferred,
351 notes: String::new(),
352 }]);
353 let notes = vec![parse_note_line(
354 &json!({"id":"n1","area_id":"a1","kind":"meta","claim":"out of scope for this sprint"}),
355 1,
356 )];
357 let stats = compute_coverage_stats(&inv, ¬es, &ScratchpadConfig::default());
358 assert_eq!(stats.areas_accounted, 1);
359 assert!((stats.accounted_ratio - 1.0).abs() < f64::EPSILON);
360 }
361
362 #[test]
363 fn done_with_meta_only_not_accounted() {
364 let inv = inv_with_areas(vec![InventoryArea {
365 id: "area-types".into(),
366 path: "frontend/src/types".into(),
367 status: AreaStatus::Done,
368 notes: String::new(),
369 }]);
370 let notes = vec![parse_note_line(
371 &json!({"id":"n1","area_id":"area-types","kind":"meta","claim":"audit complete summary"}),
372 1,
373 )];
374 let cfg = ScratchpadConfig::default();
375 let stats = compute_coverage_stats(&inv, ¬es, &cfg);
376 assert_eq!(stats.areas_accounted, 0);
377 let gaps = areas_failing_quality_gate(&inv, ¬es, &cfg);
378 assert_eq!(gaps.len(), 1);
379 assert_eq!(gaps[0].id, "area-types");
380 let outcome = coverage_gate(&inv, ¬es, &cfg);
381 if let CoverageGateOutcome::Block { reason, .. } = outcome {
382 assert!(reason.contains("area-types"));
383 assert!(reason.contains("kind=cleared"));
384 } else {
385 panic!("expected block for meta-only done area");
386 }
387 }
388}