koala_drift/checks/
adr_dormancy.rs1use crate::check::{Check, Finding, FindingKind, Severity};
6use crate::scan::{list_adr_files, list_all_wiki_md, rel};
7use koala_core::invariant::Context;
8use koala_core::wiki::{extract_frontmatter, parse_yaml_frontmatter};
9use regex::Regex;
10use std::fs;
11use std::path::Path;
12use std::sync::OnceLock;
13
14pub struct AdrDormancy;
15
16fn adr_pattern() -> &'static Regex {
17 static R: OnceLock<Regex> = OnceLock::new();
18 R.get_or_init(|| Regex::new(r"\bADR-(\d{4})\b").expect("static regex compiles"))
20}
21
22impl Check for AdrDormancy {
23 fn id(&self) -> &'static str {
24 "adr.dormancy-advisory"
25 }
26
27 fn intent(&self) -> &'static str {
28 "Accepted ADRs that nothing in the wiki references any more get an \
29 advisory ping for the next pruning sprint."
30 }
31
32 fn run(&self, ctx: &Context) -> Vec<Finding> {
33 let adrs = list_adr_files(ctx.root());
34 if adrs.is_empty() {
35 return Vec::new();
36 }
37
38 let mut inbound: std::collections::HashMap<String, usize> =
43 std::collections::HashMap::new();
44 let all = list_all_wiki_md(ctx.root());
45 let adr_paths: std::collections::HashSet<&Path> =
46 adrs.iter().map(|p| p.as_path()).collect();
47 for path in &all {
48 if adr_paths.contains(path.as_path()) {
49 continue;
50 }
51 let Ok(content) = fs::read_to_string(path) else {
52 continue;
53 };
54 let mut seen_in_file: std::collections::HashSet<String> =
55 std::collections::HashSet::new();
56 for caps in adr_pattern().captures_iter(&content) {
57 let id = caps.get(1).expect("group 1").as_str().to_string();
58 seen_in_file.insert(id);
59 }
60 for id in seen_in_file {
61 *inbound.entry(id).or_default() += 1;
62 }
63 }
64
65 let mut out = Vec::new();
66 for path in adrs {
67 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
68 continue;
69 };
70 let id = &name[..4];
71 let Ok(content) = fs::read_to_string(&path) else {
72 continue;
73 };
74 let fm = extract_frontmatter(&content).unwrap_or("");
75 let fields = parse_yaml_frontmatter(fm);
76 let status = fields
77 .get("status")
78 .map(String::as_str)
79 .unwrap_or("unknown");
80 if status != "accepted" {
81 continue;
82 }
83 if inbound.get(id).copied().unwrap_or(0) > 0 {
84 continue;
85 }
86 out.push(Finding {
87 check_id: self.id(),
88 file: rel(&path, ctx.root()),
89 line: 1,
90 claim: format!("ADR-{id} accepted with no inbound wiki references"),
91 kind: FindingKind::AdrDormant,
92 severity: Severity::Advisory,
93 fix_hint: Some(
94 "next pruning sprint: confirm still load-bearing or supersede".to_string(),
95 ),
96 });
97 }
98 out
99 }
100}