Skip to main content

koala_drift/checks/
adr_dormancy.rs

1//! `adr.dormancy-advisory` — accepted ADRs with zero inbound references
2//! anywhere in `wiki/` get an advisory finding (never blocks). The
3//! signal feeds the quarterly pruning sprint (see ADR-0010).
4
5use 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    // See `feature_adr_refs::adr_pattern` for the word-boundary rationale.
19    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        // Count inbound references per ADR id, scanning every other md file
39        // under wiki/. We don't strip code fences here — example output that
40        // mentions ADR-NNNN still counts as engagement; the signal is "is
41        // anyone talking about this ADR at all".
42        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}