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]
247pub fn partial_closeout_approved(notes: &[NoteLine]) -> bool {
248 notes.iter().any(|n| {
249 n.area_id == "_global"
250 && n.kind == "meta"
251 && n.claim.as_ref().is_some_and(|c| {
252 let lower = c.to_lowercase();
253 lower.contains("partial_closeout")
254 || lower.contains("部分收口")
255 || lower.contains("partial audit close")
256 })
257 })
258}
259
260#[must_use]
261pub fn format_reviewed_gate_block_reason(
262 stats: &CoverageStats,
263 config: &ScratchpadConfig,
264) -> String {
265 format!(
266 "reviewed_ratio {:.0}% is below hard threshold {:.0}% ({} of {} areas actually examined with finding/cleared; deferred-only does not count). \
267 Continue P1 on more areas, or append `_global` meta with `partial_closeout` / `部分收口` if the user explicitly approved a partial report.",
268 stats.reviewed_ratio * 100.0,
269 config.coverage_reviewed_hard_ratio * 100.0,
270 stats.areas_reviewed,
271 stats.areas_total,
272 )
273}
274
275#[must_use]
276pub fn coverage_gate(
277 inventory: &Inventory,
278 notes: &[NoteLine],
279 config: &ScratchpadConfig,
280) -> CoverageGateOutcome {
281 let stats = compute_coverage_stats(inventory, notes, config);
282
283 if stats.areas_total == 0 {
284 return CoverageGateOutcome::Allow { stats };
285 }
286
287 if stats.accounted_ratio < config.coverage_hard_ratio && config.coverage_hard_block_enabled {
288 let gaps = areas_failing_quality_gate(inventory, notes, config);
289 let reason = format_quality_gate_block_reason(&stats, &gaps, config);
290 return CoverageGateOutcome::Block { stats, reason };
291 }
292
293 if config.coverage_reviewed_hard_block_enabled
294 && stats.reviewed_ratio < config.coverage_reviewed_hard_ratio
295 && !partial_closeout_approved(notes)
296 {
297 let reason = format_reviewed_gate_block_reason(&stats, config);
298 return CoverageGateOutcome::Block { stats, reason };
299 }
300
301 if stats.accounted_ratio < config.coverage_soft_ratio {
302 let pending = stats.pending_area_ids.join(", ");
303 let warning = format!(
304 "WARNING: {} area(s) pending — continue review or scratchpad_set_area(deferred) with kind=meta reason.\n\
305 pending_area_ids: [{pending}]",
306 stats.pending_area_ids.len(),
307 );
308 return CoverageGateOutcome::Warn {
309 stats,
310 warning_text: warning,
311 };
312 }
313
314 CoverageGateOutcome::Allow { stats }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::scratchpad::schema::{InventoryArea, parse_note_line};
321 use serde_json::json;
322
323 fn inv_with_areas(areas: Vec<InventoryArea>) -> Inventory {
324 Inventory {
325 run_id: "r".into(),
326 created_at: String::new(),
327 completed_at: None,
328 scope: None,
329 areas,
330 }
331 }
332
333 #[test]
334 fn empty_deferred_without_meta_not_accounted() {
335 let inv = inv_with_areas(vec![
336 InventoryArea {
337 id: "a1".into(),
338 path: "p".into(),
339 status: AreaStatus::Deferred,
340 notes: String::new(),
341 },
342 InventoryArea {
343 id: "a2".into(),
344 path: "p".into(),
345 status: AreaStatus::Done,
346 notes: String::new(),
347 },
348 ]);
349 let notes = vec![parse_note_line(
350 &json!({"id":"n1","area_id":"a2","kind":"finding","status":"verified"}),
351 1,
352 )];
353 let stats = compute_coverage_stats(&inv, ¬es, &ScratchpadConfig::default());
354 assert_eq!(stats.areas_accounted, 1);
355 assert!((stats.accounted_ratio - 0.5).abs() < f64::EPSILON);
356 }
357
358 #[test]
359 fn coverage_gate_blocks_low_accounted() {
360 let inv = inv_with_areas(vec![
361 InventoryArea {
362 id: "a1".into(),
363 path: "p".into(),
364 status: AreaStatus::Pending,
365 notes: String::new(),
366 },
367 InventoryArea {
368 id: "a2".into(),
369 path: "p".into(),
370 status: AreaStatus::Pending,
371 notes: String::new(),
372 },
373 ]);
374 let cfg = ScratchpadConfig {
375 coverage_hard_ratio: 0.6,
376 coverage_soft_ratio: 0.85,
377 ..Default::default()
378 };
379 let outcome = coverage_gate(&inv, &[], &cfg);
380 assert!(matches!(outcome, CoverageGateOutcome::Block { .. }));
381 }
382
383 #[test]
384 fn deferred_with_meta_counts_accounted() {
385 let inv = inv_with_areas(vec![InventoryArea {
386 id: "a1".into(),
387 path: "p".into(),
388 status: AreaStatus::Deferred,
389 notes: String::new(),
390 }]);
391 let notes = vec![parse_note_line(
392 &json!({"id":"n1","area_id":"a1","kind":"meta","claim":"out of scope for this sprint"}),
393 1,
394 )];
395 let stats = compute_coverage_stats(&inv, ¬es, &ScratchpadConfig::default());
396 assert_eq!(stats.areas_accounted, 1);
397 assert!((stats.accounted_ratio - 1.0).abs() < f64::EPSILON);
398 }
399
400 #[test]
401 fn done_with_meta_only_not_accounted() {
402 let inv = inv_with_areas(vec![InventoryArea {
403 id: "area-types".into(),
404 path: "frontend/src/types".into(),
405 status: AreaStatus::Done,
406 notes: String::new(),
407 }]);
408 let notes = vec![parse_note_line(
409 &json!({"id":"n1","area_id":"area-types","kind":"meta","claim":"audit complete summary"}),
410 1,
411 )];
412 let cfg = ScratchpadConfig::default();
413 let stats = compute_coverage_stats(&inv, ¬es, &cfg);
414 assert_eq!(stats.areas_accounted, 0);
415 let gaps = areas_failing_quality_gate(&inv, ¬es, &cfg);
416 assert_eq!(gaps.len(), 1);
417 assert_eq!(gaps[0].id, "area-types");
418 let outcome = coverage_gate(&inv, ¬es, &cfg);
419 if let CoverageGateOutcome::Block { reason, .. } = outcome {
420 assert!(reason.contains("area-types"));
421 assert!(reason.contains("kind=cleared"));
422 } else {
423 panic!("expected block for meta-only done area");
424 }
425 }
426
427 #[test]
428 fn coverage_gate_blocks_mass_defer_despite_full_accounted() {
429 let mut areas = Vec::new();
430 for i in 0..9 {
431 areas.push(InventoryArea {
432 id: format!("done-{i}"),
433 path: "p".into(),
434 status: AreaStatus::Done,
435 notes: String::new(),
436 });
437 }
438 for i in 0..28 {
439 areas.push(InventoryArea {
440 id: format!("def-{i}"),
441 path: "p".into(),
442 status: AreaStatus::Deferred,
443 notes: String::new(),
444 });
445 }
446 let inv = inv_with_areas(areas);
447 let mut notes = Vec::new();
448 for i in 0..9 {
449 notes.push(parse_note_line(
450 &json!({"id":format!("f-{i}"),"area_id":format!("done-{i}"),"kind":"finding","status":"verified"}),
451 i + 1,
452 ));
453 }
454 for i in 0..28 {
455 notes.push(parse_note_line(
456 &json!({"id":format!("m-{i}"),"area_id":format!("def-{i}"),"kind":"meta","claim":"deferred: session limit"}),
457 100 + i,
458 ));
459 }
460 let cfg = ScratchpadConfig::default();
461 let stats = compute_coverage_stats(&inv, ¬es, &cfg);
462 assert!((stats.accounted_ratio - 1.0).abs() < f64::EPSILON);
463 assert!(stats.reviewed_ratio < 0.40);
464 let outcome = coverage_gate(&inv, ¬es, &cfg);
465 assert!(matches!(outcome, CoverageGateOutcome::Block { .. }));
466 }
467
468 #[test]
469 fn partial_closeout_bypasses_reviewed_hard_gate() {
470 let inv = inv_with_areas(vec![
471 InventoryArea {
472 id: "done-0".into(),
473 path: "p".into(),
474 status: AreaStatus::Done,
475 notes: String::new(),
476 },
477 InventoryArea {
478 id: "def-0".into(),
479 path: "p".into(),
480 status: AreaStatus::Deferred,
481 notes: String::new(),
482 },
483 ]);
484 let notes = vec![
485 parse_note_line(
486 &json!({"id":"f1","area_id":"done-0","kind":"finding","status":"verified"}),
487 1,
488 ),
489 parse_note_line(
490 &json!({"id":"m1","area_id":"def-0","kind":"meta","claim":"deferred: time"}),
491 2,
492 ),
493 parse_note_line(
494 &json!({"id":"pc","area_id":"_global","kind":"meta","claim":"partial_closeout: user approved partial report"}),
495 3,
496 ),
497 ];
498 let outcome = coverage_gate(&inv, ¬es, &ScratchpadConfig::default());
499 assert!(matches!(outcome, CoverageGateOutcome::Allow { .. }));
500 }
501}