1use crate::scratchpad::config::ScratchpadConfig;
4use crate::scratchpad::schema::{AreaStatus, Inventory, NoteLine};
5use crate::scratchpad::summary::compute_superseded_ids;
6
7pub use crate::scratchpad::note_quality::{area_meets_deferred_quality, area_meets_done_quality};
8
9#[derive(Debug, Clone)]
11pub struct CoverageStats {
12 pub areas_total: usize,
13 pub areas_accounted: usize,
14 pub areas_reviewed: usize,
15 pub accounted_ratio: f64,
16 pub reviewed_ratio: f64,
17 pub pending_area_ids: Vec<String>,
18 pub deferred_areas: Vec<DeferredAreaSummary>,
19 pub verified_findings: usize,
20}
21
22#[derive(Debug, Clone)]
23pub struct DeferredAreaSummary {
24 pub id: String,
25 pub reason_excerpt: String,
26}
27
28#[derive(Debug, Clone)]
30pub struct AreaQualityGap {
31 pub id: String,
32 pub status: String,
33 pub fix: String,
34}
35
36#[derive(Debug, Clone)]
37pub enum CoverageGateOutcome {
38 Allow {
39 stats: CoverageStats,
40 },
41 Warn {
42 stats: CoverageStats,
43 warning_text: String,
44 },
45 Block {
46 stats: CoverageStats,
47 reason: String,
48 },
49}
50
51#[must_use]
52pub fn compute_coverage_stats(
53 inventory: &Inventory,
54 notes: &[NoteLine],
55 config: &ScratchpadConfig,
56) -> CoverageStats {
57 let superseded = compute_superseded_ids(notes);
58 let verified_findings = notes
59 .iter()
60 .filter(|n| {
61 n.kind == "finding"
62 && n.status.eq_ignore_ascii_case("verified")
63 && !superseded.contains(&n.id)
64 })
65 .count();
66
67 let areas_total = inventory.areas.len();
68 let mut areas_accounted = 0usize;
69 let mut areas_reviewed = 0usize;
70 let mut pending_area_ids = Vec::new();
71 let mut deferred_areas = Vec::new();
72
73 for area in &inventory.areas {
74 match area.status {
75 AreaStatus::Pending | AreaStatus::InProgress => {
76 pending_area_ids.push(area.id.clone());
77 }
78 AreaStatus::Done => {
79 if area_meets_done_quality(&area.id, notes) {
80 areas_accounted += 1;
81 areas_reviewed += 1;
82 }
83 }
84 AreaStatus::Deferred => {
85 if config.coverage_count_deferred_as_accounted
86 && area_meets_deferred_quality(&area.id, notes)
87 {
88 areas_accounted += 1;
89 if let Some(reason) = deferred_reason_excerpt(&area.id, notes) {
90 deferred_areas.push(DeferredAreaSummary {
91 id: area.id.clone(),
92 reason_excerpt: reason,
93 });
94 }
95 }
96 }
97 }
98 }
99
100 let accounted_ratio = ratio(areas_accounted, areas_total);
101 let reviewed_ratio = ratio(areas_reviewed, areas_total);
102
103 CoverageStats {
104 areas_total,
105 areas_accounted,
106 areas_reviewed,
107 accounted_ratio,
108 reviewed_ratio,
109 pending_area_ids,
110 deferred_areas,
111 verified_findings,
112 }
113}
114
115fn ratio(num: usize, den: usize) -> f64 {
116 if den == 0 {
117 1.0
118 } else {
119 num as f64 / den as f64
120 }
121}
122
123fn deferred_reason_excerpt(area_id: &str, notes: &[NoteLine]) -> Option<String> {
124 notes
125 .iter()
126 .filter(|n| n.area_id == area_id && n.kind == "meta")
127 .find_map(|n| n.claim.as_ref().filter(|c| !c.trim().is_empty()))
128 .map(|c| {
129 let t = c.trim();
130 if t.chars().count() > 120 {
131 let head: String = t.chars().take(120).collect();
132 format!("{head}…")
133 } else {
134 t.to_string()
135 }
136 })
137}
138
139#[must_use]
141pub fn build_l0_status_line(run_id: &str, stats: &CoverageStats, resume_area_id: &str) -> String {
142 let accounted_pct = (stats.accounted_ratio * 100.0).round() as u32;
143 format!(
144 "run_id={run_id} areas {}/{} accounted ({}%), {} reviewed; resume_area_id={}; verified_findings={}",
145 stats.areas_accounted,
146 stats.areas_total,
147 accounted_pct,
148 stats.areas_reviewed,
149 resume_area_id,
150 stats.verified_findings,
151 )
152}
153
154#[must_use]
155pub fn resume_area_id_from_inventory(inventory: &Inventory) -> String {
156 inventory
157 .areas
158 .iter()
159 .find(|a| matches!(a.status, AreaStatus::Pending | AreaStatus::InProgress))
160 .map(|a| a.id.as_str())
161 .unwrap_or("none")
162 .to_string()
163}
164
165#[must_use]
167pub fn areas_failing_quality_gate(
168 inventory: &Inventory,
169 notes: &[NoteLine],
170 config: &ScratchpadConfig,
171) -> Vec<AreaQualityGap> {
172 let mut gaps = Vec::new();
173 for area in &inventory.areas {
174 match area.status {
175 AreaStatus::Done if !area_meets_done_quality(&area.id, notes) => {
176 gaps.push(AreaQualityGap {
177 id: area.id.clone(),
178 status: "done".into(),
179 fix: "scratchpad_append kind=finding or kind=cleared with [D#] evidence (meta-only notes do not count for done)".into(),
180 });
181 }
182 AreaStatus::Deferred
183 if config.coverage_count_deferred_as_accounted
184 && !area_meets_deferred_quality(&area.id, notes) =>
185 {
186 gaps.push(AreaQualityGap {
187 id: area.id.clone(),
188 status: "deferred".into(),
189 fix: "scratchpad_append kind=meta with non-empty defer reason (not security-risk-only stub)".into(),
190 });
191 }
192 _ => {}
193 }
194 }
195 gaps
196}
197
198#[must_use]
199pub fn format_quality_gate_block_reason(
200 stats: &CoverageStats,
201 gaps: &[AreaQualityGap],
202 config: &ScratchpadConfig,
203) -> String {
204 let mut reason = format!(
205 "accounted_ratio {:.0}% is below hard threshold {:.0}% ({} of {} areas meet quality gates)",
206 stats.accounted_ratio * 100.0,
207 config.coverage_hard_ratio * 100.0,
208 stats.areas_accounted,
209 stats.areas_total,
210 );
211 if !gaps.is_empty() {
212 reason.push_str("; areas failing quality gates:");
213 for gap in gaps.iter().take(12) {
214 reason.push_str(&format!("\n- {} ({}) — {}", gap.id, gap.status, gap.fix));
215 }
216 if gaps.len() > 12 {
217 reason.push_str(&format!("\n- … and {} more", gaps.len() - 12));
218 }
219 } else if !stats.pending_area_ids.is_empty() {
220 reason.push_str("; finish pending areas or mark deferred with kind=meta reason");
221 } else {
222 reason.push_str(
223 "; for each done area without findings use kind=cleared, not meta-only summaries",
224 );
225 }
226 reason
227}
228
229#[must_use]
231pub fn partial_closeout_approved(notes: &[NoteLine]) -> bool {
232 notes.iter().any(|n| {
233 n.area_id == "_global"
234 && n.kind == "meta"
235 && n.claim.as_ref().is_some_and(|c| {
236 let lower = c.to_lowercase();
237 lower.contains("partial_closeout")
238 || lower.contains("部分收口")
239 || lower.contains("partial audit close")
240 })
241 })
242}
243
244#[must_use]
245pub fn format_reviewed_gate_block_reason(
246 stats: &CoverageStats,
247 config: &ScratchpadConfig,
248) -> String {
249 format!(
250 "reviewed_ratio {:.0}% is below hard threshold {:.0}% ({} of {} areas actually examined with finding/cleared; deferred-only does not count). \
251 Continue P1 on more areas, or append `_global` meta with `partial_closeout` / `部分收口` if the user explicitly approved a partial report.",
252 stats.reviewed_ratio * 100.0,
253 config.coverage_reviewed_hard_ratio * 100.0,
254 stats.areas_reviewed,
255 stats.areas_total,
256 )
257}
258
259#[must_use]
260pub fn coverage_gate(
261 inventory: &Inventory,
262 notes: &[NoteLine],
263 config: &ScratchpadConfig,
264) -> CoverageGateOutcome {
265 let stats = compute_coverage_stats(inventory, notes, config);
266
267 if stats.areas_total == 0 {
268 return CoverageGateOutcome::Allow { stats };
269 }
270
271 if stats.accounted_ratio < config.coverage_hard_ratio && config.coverage_hard_block_enabled {
272 let gaps = areas_failing_quality_gate(inventory, notes, config);
273 let reason = format_quality_gate_block_reason(&stats, &gaps, config);
274 return CoverageGateOutcome::Block { stats, reason };
275 }
276
277 if config.coverage_reviewed_hard_block_enabled
278 && stats.reviewed_ratio < config.coverage_reviewed_hard_ratio
279 && !partial_closeout_approved(notes)
280 {
281 let reason = format_reviewed_gate_block_reason(&stats, config);
282 return CoverageGateOutcome::Block { stats, reason };
283 }
284
285 if stats.accounted_ratio < config.coverage_soft_ratio {
286 let pending = stats.pending_area_ids.join(", ");
287 let warning = format!(
288 "WARNING: {} area(s) pending — continue review or scratchpad_set_area(deferred) with kind=meta reason.\n\
289 pending_area_ids: [{pending}]",
290 stats.pending_area_ids.len(),
291 );
292 return CoverageGateOutcome::Warn {
293 stats,
294 warning_text: warning,
295 };
296 }
297
298 CoverageGateOutcome::Allow { stats }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::scratchpad::schema::{InventoryArea, parse_note_line};
305 use serde_json::json;
306
307 fn inv_with_areas(areas: Vec<InventoryArea>) -> Inventory {
308 Inventory {
309 run_id: "r".into(),
310 created_at: String::new(),
311 completed_at: None,
312 scope: None,
313 areas,
314 }
315 }
316
317 #[test]
318 fn empty_deferred_without_meta_not_accounted() {
319 let inv = inv_with_areas(vec![
320 InventoryArea {
321 id: "a1".into(),
322 path: "p".into(),
323 status: AreaStatus::Deferred,
324 notes: String::new(),
325 },
326 InventoryArea {
327 id: "a2".into(),
328 path: "p".into(),
329 status: AreaStatus::Done,
330 notes: String::new(),
331 },
332 ]);
333 let notes = vec![parse_note_line(
334 &json!({"id":"n1","area_id":"a2","kind":"finding","status":"verified"}),
335 1,
336 )];
337 let stats = compute_coverage_stats(&inv, ¬es, &ScratchpadConfig::default());
338 assert_eq!(stats.areas_accounted, 1);
339 assert!((stats.accounted_ratio - 0.5).abs() < f64::EPSILON);
340 }
341
342 #[test]
343 fn coverage_gate_blocks_low_accounted() {
344 let inv = inv_with_areas(vec![
345 InventoryArea {
346 id: "a1".into(),
347 path: "p".into(),
348 status: AreaStatus::Pending,
349 notes: String::new(),
350 },
351 InventoryArea {
352 id: "a2".into(),
353 path: "p".into(),
354 status: AreaStatus::Pending,
355 notes: String::new(),
356 },
357 ]);
358 let cfg = ScratchpadConfig {
359 coverage_hard_ratio: 0.6,
360 coverage_soft_ratio: 0.85,
361 ..Default::default()
362 };
363 let outcome = coverage_gate(&inv, &[], &cfg);
364 assert!(matches!(outcome, CoverageGateOutcome::Block { .. }));
365 }
366
367 #[test]
368 fn deferred_with_meta_counts_accounted() {
369 let inv = inv_with_areas(vec![InventoryArea {
370 id: "a1".into(),
371 path: "p".into(),
372 status: AreaStatus::Deferred,
373 notes: String::new(),
374 }]);
375 let notes = vec![parse_note_line(
376 &json!({"id":"n1","area_id":"a1","kind":"meta","claim":"out of scope for this sprint"}),
377 1,
378 )];
379 let stats = compute_coverage_stats(&inv, ¬es, &ScratchpadConfig::default());
380 assert_eq!(stats.areas_accounted, 1);
381 assert!((stats.accounted_ratio - 1.0).abs() < f64::EPSILON);
382 }
383
384 #[test]
385 fn done_with_meta_only_not_accounted() {
386 let inv = inv_with_areas(vec![InventoryArea {
387 id: "area-types".into(),
388 path: "frontend/src/types".into(),
389 status: AreaStatus::Done,
390 notes: String::new(),
391 }]);
392 let notes = vec![parse_note_line(
393 &json!({"id":"n1","area_id":"area-types","kind":"meta","claim":"audit complete summary"}),
394 1,
395 )];
396 let cfg = ScratchpadConfig::default();
397 let stats = compute_coverage_stats(&inv, ¬es, &cfg);
398 assert_eq!(stats.areas_accounted, 0);
399 let gaps = areas_failing_quality_gate(&inv, ¬es, &cfg);
400 assert_eq!(gaps.len(), 1);
401 assert_eq!(gaps[0].id, "area-types");
402 let outcome = coverage_gate(&inv, ¬es, &cfg);
403 if let CoverageGateOutcome::Block { reason, .. } = outcome {
404 assert!(reason.contains("area-types"));
405 assert!(reason.contains("kind=cleared"));
406 } else {
407 panic!("expected block for meta-only done area");
408 }
409 }
410
411 #[test]
412 fn coverage_gate_blocks_mass_defer_despite_full_accounted() {
413 let mut areas = Vec::new();
414 for i in 0..9 {
415 areas.push(InventoryArea {
416 id: format!("done-{i}"),
417 path: "p".into(),
418 status: AreaStatus::Done,
419 notes: String::new(),
420 });
421 }
422 for i in 0..28 {
423 areas.push(InventoryArea {
424 id: format!("def-{i}"),
425 path: "p".into(),
426 status: AreaStatus::Deferred,
427 notes: String::new(),
428 });
429 }
430 let inv = inv_with_areas(areas);
431 let mut notes = Vec::new();
432 for i in 0..9 {
433 notes.push(parse_note_line(
434 &json!({"id":format!("f-{i}"),"area_id":format!("done-{i}"),"kind":"finding","status":"verified"}),
435 i + 1,
436 ));
437 }
438 for i in 0..28 {
439 notes.push(parse_note_line(
440 &json!({"id":format!("m-{i}"),"area_id":format!("def-{i}"),"kind":"meta","claim":"deferred: session limit"}),
441 100 + i,
442 ));
443 }
444 let cfg = ScratchpadConfig::default();
445 let stats = compute_coverage_stats(&inv, ¬es, &cfg);
446 assert!((stats.accounted_ratio - 1.0).abs() < f64::EPSILON);
447 assert!(stats.reviewed_ratio < 0.40);
448 let outcome = coverage_gate(&inv, ¬es, &cfg);
449 assert!(matches!(outcome, CoverageGateOutcome::Block { .. }));
450 }
451
452 #[test]
453 fn partial_closeout_bypasses_reviewed_hard_gate() {
454 let inv = inv_with_areas(vec![
455 InventoryArea {
456 id: "done-0".into(),
457 path: "p".into(),
458 status: AreaStatus::Done,
459 notes: String::new(),
460 },
461 InventoryArea {
462 id: "def-0".into(),
463 path: "p".into(),
464 status: AreaStatus::Deferred,
465 notes: String::new(),
466 },
467 ]);
468 let notes = vec![
469 parse_note_line(
470 &json!({"id":"f1","area_id":"done-0","kind":"finding","status":"verified"}),
471 1,
472 ),
473 parse_note_line(
474 &json!({"id":"m1","area_id":"def-0","kind":"meta","claim":"deferred: time"}),
475 2,
476 ),
477 parse_note_line(
478 &json!({"id":"pc","area_id":"_global","kind":"meta","claim":"partial_closeout: user approved partial report"}),
479 3,
480 ),
481 ];
482 let outcome = coverage_gate(&inv, ¬es, &ScratchpadConfig::default());
483 assert!(matches!(outcome, CoverageGateOutcome::Allow { .. }));
484 }
485}