koala_drift/checks/
adr_no_delete.rs1use crate::check::{Check, Finding, FindingKind, Severity};
15use koala_adr::Registry as AdrRegistry;
16use koala_core::invariant::Context;
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20pub struct AdrNoDelete;
21
22impl Check for AdrNoDelete {
23 fn id(&self) -> &'static str {
24 "adr.no-delete-accepted"
25 }
26
27 fn intent(&self) -> &'static str {
28 "Every ADR id ever committed must still be present at HEAD. \
29 Gaps and deleted-tip ADRs both fail (ADR-0008: ADRs are \
30 immutable, supersede instead)."
31 }
32
33 fn run(&self, ctx: &Context) -> Vec<Finding> {
34 let Ok(reg) = AdrRegistry::load(ctx.root()) else {
35 return Vec::new();
36 };
37 let mut current: Vec<u32> = reg.entries().iter().map(|e| e.frontmatter.id).collect();
38 current.sort_unstable();
39 let present: HashSet<u32> = current.iter().copied().collect();
40
41 let mut historical: HashSet<u32> = present.clone();
43 if let Some(deleted) = git_deleted_adr_ids(ctx.root()) {
44 historical.extend(deleted);
45 }
46
47 let max = match historical.iter().max().copied() {
51 Some(m) => m,
52 None => return Vec::new(),
53 };
54
55 let display = PathBuf::from("wiki/decisions/");
56 let mut out = Vec::new();
57 for missing in 1..=max {
58 if present.contains(&missing) {
59 continue;
60 }
61 let was_deleted = historical.contains(&missing);
62 let claim = if was_deleted {
63 format!("ADR-{missing:04} once committed, now deleted")
64 } else {
65 format!("ADR-{missing:04} missing")
66 };
67 out.push(Finding {
68 check_id: self.id(),
69 file: display.clone(),
70 line: 0,
71 claim,
72 kind: FindingKind::AdrIdGap { missing },
73 severity: Severity::Hard,
74 fix_hint: Some(format!(
75 "Restore ADR-{missing:04} (ADR-0008: ADRs are immutable). \
76 If it was wrongly created, mark it status=deprecated rather than deleting."
77 )),
78 });
79 }
80 out
81 }
82}
83
84fn git_deleted_adr_ids(root: &Path) -> Option<HashSet<u32>> {
89 let out = std::process::Command::new("git")
90 .arg("-C")
91 .arg(root)
92 .args([
93 "log",
94 "--all",
95 "--diff-filter=D",
96 "--name-only",
97 "--pretty=format:",
98 "--",
99 "wiki/decisions/",
100 ])
101 .output()
102 .ok()?;
103 if !out.status.success() {
104 return None;
105 }
106 let text = String::from_utf8_lossy(&out.stdout);
107 let mut ids: HashSet<u32> = HashSet::new();
108 for line in text.lines() {
109 let trimmed = line.trim();
110 if trimmed.is_empty() {
111 continue;
112 }
113 let leaf = trimmed.split('/').next_back().unwrap_or(trimmed);
114 if leaf.starts_with('_') {
115 continue;
116 }
117 if let Some(prefix) = leaf.split('-').next() {
118 if let Ok(n) = prefix.parse::<u32>() {
119 ids.insert(n);
120 }
121 }
122 }
123 Some(ids)
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use koala_core::invariant::Context;
130 use std::fs;
131 use tempfile::TempDir;
132
133 fn write_adr(root: &std::path::Path, id: u32) {
134 let dir = root.join("wiki/decisions");
135 fs::create_dir_all(&dir).unwrap();
136 let body = format!(
137 "---\n\
138id: {id:04}\n\
139title: ADR {id}\n\
140status: accepted\n\
141date: 2026-01-01\n\
142---\n\n# ADR-{id:04}: ADR {id}\n"
143 );
144 fs::write(dir.join(format!("{id:04}-adr.md")), body).unwrap();
145 }
146
147 #[test]
148 fn contiguous_ids_pass() {
149 let tmp = TempDir::new().unwrap();
150 for id in 1..=3 {
151 write_adr(tmp.path(), id);
152 }
153 let ctx = Context::new(tmp.path());
154 let findings = AdrNoDelete.run(&ctx);
155 assert!(findings.is_empty(), "{findings:?}");
156 }
157
158 #[test]
159 fn empty_registry_passes() {
160 let tmp = TempDir::new().unwrap();
161 let ctx = Context::new(tmp.path());
162 let findings = AdrNoDelete.run(&ctx);
163 assert!(findings.is_empty());
164 }
165
166 #[test]
167 fn accepted_adr_deletion_blocks() {
168 let tmp = TempDir::new().unwrap();
169 write_adr(tmp.path(), 1);
170 write_adr(tmp.path(), 2);
171 write_adr(tmp.path(), 4);
172 let ctx = Context::new(tmp.path());
173 let findings = AdrNoDelete.run(&ctx);
174 assert_eq!(findings.len(), 1);
175 let f = &findings[0];
176 assert_eq!(f.check_id, "adr.no-delete-accepted");
177 assert_eq!(f.severity, Severity::Hard);
178 assert!(matches!(f.kind, FindingKind::AdrIdGap { missing: 3 }));
179 }
180
181 #[test]
182 fn multiple_gaps_each_reported() {
183 let tmp = TempDir::new().unwrap();
184 write_adr(tmp.path(), 1);
185 write_adr(tmp.path(), 5);
186 let ctx = Context::new(tmp.path());
187 let findings = AdrNoDelete.run(&ctx);
188 let missing: Vec<u32> = findings
189 .iter()
190 .filter_map(|f| match f.kind {
191 FindingKind::AdrIdGap { missing } => Some(missing),
192 _ => None,
193 })
194 .collect();
195 assert_eq!(missing, vec![2, 3, 4]);
196 }
197
198 #[test]
203 fn deleted_tip_adr_caught_via_git_history() {
204 let tmp = TempDir::new().unwrap();
205 let root = tmp.path();
206
207 let run = |args: &[&str]| {
209 std::process::Command::new("git")
210 .args(args)
211 .current_dir(root)
212 .output()
213 .expect("git invocation")
214 };
215 run(&["init", "--initial-branch=main"]);
216 run(&["config", "user.email", "test@example.com"]);
217 run(&["config", "user.name", "Test"]);
218 run(&["config", "commit.gpgsign", "false"]);
219
220 write_adr(root, 1);
221 write_adr(root, 2);
222 run(&["add", "-A"]);
223 run(&["commit", "-m", "two adrs"]);
224
225 std::fs::remove_file(root.join("wiki/decisions/0002-adr.md")).unwrap();
227 run(&["add", "-A"]);
228 run(&["commit", "-m", "delete ADR-2"]);
229
230 let ctx = Context::new(root);
231 let findings = AdrNoDelete.run(&ctx);
232 assert!(
233 findings
234 .iter()
235 .any(|f| matches!(f.kind, FindingKind::AdrIdGap { missing: 2 })),
236 "expected ADR-2 deletion to be caught, got {findings:?}"
237 );
238 let claim = findings
240 .iter()
241 .find(|f| matches!(f.kind, FindingKind::AdrIdGap { missing: 2 }))
242 .map(|f| f.claim.clone())
243 .unwrap_or_default();
244 assert!(
245 claim.contains("once committed"),
246 "claim should distinguish deletion from gap: {claim}"
247 );
248 }
249}