Skip to main content

koala_drift/checks/
feature_adr_refs.rs

1//! `feature.adr-ref-resolves` — every `ADR-NNNN` mentioned in a feature
2//! file (outside fenced code blocks) must point to an existing decision
3//! file. References to a `superseded` ADR fail with a hint to use the
4//! superseder (see ADR-0008).
5
6use crate::check::{Check, Finding, FindingKind, Severity};
7use crate::scan::{list_adr_files, list_feature_files, rel, tagged_lines};
8use koala_core::invariant::Context;
9use koala_core::wiki::{extract_frontmatter, parse_yaml_frontmatter};
10use regex::Regex;
11use std::collections::HashMap;
12use std::fs;
13use std::path::Path;
14use std::sync::OnceLock;
15
16pub struct FeatureAdrRefs;
17
18#[derive(Debug)]
19struct AdrRecord {
20    status: String,
21    superseded_by: Option<String>,
22}
23
24fn adr_pattern() -> &'static Regex {
25    static R: OnceLock<Regex> = OnceLock::new();
26    // Word boundaries prevent silent absorption of `ADR-99999` (5-digit
27    // typo) into `ADR-9999` and similar — exactly the kind of "silent
28    // typo" failure mode that drift detection exists to flag, not hide.
29    R.get_or_init(|| Regex::new(r"\bADR-(\d{4})\b").expect("static regex compiles"))
30}
31
32impl Check for FeatureAdrRefs {
33    fn id(&self) -> &'static str {
34        "feature.adr-ref-resolves"
35    }
36
37    fn intent(&self) -> &'static str {
38        "ADR references in feature docs resolve to an existing decision \
39         file and are not superseded."
40    }
41
42    fn run(&self, ctx: &Context) -> Vec<Finding> {
43        let adr_index = build_adr_index(ctx.root());
44        let mut out = Vec::new();
45        for feature_path in list_feature_files(ctx.root()) {
46            let Ok(content) = fs::read_to_string(&feature_path) else {
47                continue;
48            };
49            let display = rel(&feature_path, ctx.root());
50            for line in tagged_lines(&content) {
51                if line.in_fence {
52                    continue;
53                }
54                let mut seen_on_line: Vec<&str> = Vec::new();
55                for m in adr_pattern().captures_iter(line.text) {
56                    let id = m.get(1).expect("group 1 always present").as_str();
57                    if seen_on_line.contains(&id) {
58                        continue;
59                    }
60                    seen_on_line.push(id);
61
62                    match adr_index.get(id) {
63                        None => out.push(Finding {
64                            check_id: self.id(),
65                            file: display.clone(),
66                            line: line.line_no,
67                            claim: line.text.trim().to_string(),
68                            kind: FindingKind::AdrRefDangling,
69                            severity: Severity::Hard,
70                            fix_hint: Some(format!(
71                                "no `wiki/decisions/{id}-*.md` exists; remove \
72                                 the reference or land the ADR first"
73                            )),
74                        }),
75                        Some(rec) if rec.status == "superseded" => {
76                            let superseder = rec.superseded_by.clone();
77                            let hint = match superseder.as_deref() {
78                                Some(s) => format!(
79                                    "ADR-{id} is superseded by ADR-{s}; update \
80                                     the reference"
81                                ),
82                                None => format!(
83                                    "ADR-{id} is superseded; update the \
84                                     reference to its superseder"
85                                ),
86                            };
87                            out.push(Finding {
88                                check_id: self.id(),
89                                file: display.clone(),
90                                line: line.line_no,
91                                claim: line.text.trim().to_string(),
92                                kind: FindingKind::AdrRefSuperseded { superseder },
93                                severity: Severity::Hard,
94                                fix_hint: Some(hint),
95                            });
96                        }
97                        Some(_) => {}
98                    }
99                }
100            }
101        }
102        out
103    }
104}
105
106fn build_adr_index(root: &Path) -> HashMap<String, AdrRecord> {
107    let mut out = HashMap::new();
108    for path in list_adr_files(root) {
109        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
110            continue;
111        };
112        let id = name[..4].to_string();
113        let Ok(content) = fs::read_to_string(&path) else {
114            continue;
115        };
116        let fm = extract_frontmatter(&content).unwrap_or("");
117        let fields = parse_yaml_frontmatter(fm);
118        let status = fields
119            .get("status")
120            .cloned()
121            .unwrap_or_else(|| "unknown".to_string());
122        let superseded_by = fields
123            .get("superseded-by")
124            .or_else(|| fields.get("superseded_by"))
125            .cloned();
126        out.insert(
127            id,
128            AdrRecord {
129                status,
130                superseded_by,
131            },
132        );
133    }
134    out
135}