koala_drift/checks/
feature_adr_refs.rs1use 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 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}