koala_drift/checks/
adr_graph_clean.rs1use crate::check::{Check, Finding, FindingKind, Severity};
16use crate::scan::rel;
17use koala_adr::{validate, Issue, Registry};
18use koala_core::invariant::Context;
19use std::path::PathBuf;
20
21pub struct AdrGraphClean;
22
23impl Check for AdrGraphClean {
24 fn id(&self) -> &'static str {
25 "adr.graph-clean"
26 }
27
28 fn intent(&self) -> &'static str {
29 "The ADR supersede graph must stay clean — no cycles, dangling \
30 supersede targets, status/pointer mismatches, or asymmetric links. \
31 Recomputed live so adding an ADR never false-flags (issue #23)."
32 }
33
34 fn run(&self, ctx: &Context) -> Vec<Finding> {
35 let Ok(reg) = Registry::load(ctx.root()) else {
39 return Vec::new();
40 };
41
42 validate(®)
43 .into_iter()
44 .map(|issue| {
45 let anchor = issue_anchor(&issue);
46 let file = anchor
47 .and_then(|id| {
48 reg.entries()
49 .iter()
50 .find(|e| e.frontmatter.id == id)
51 .map(|e| e.relative.clone())
52 })
53 .unwrap_or_else(|| index_path(ctx.root()));
56 Finding {
57 check_id: self.id(),
58 file,
59 line: 1,
60 claim: issue.to_string(),
61 kind: FindingKind::AdrGraphUnclean,
62 severity: Severity::Hard,
63 fix_hint: Some(fix_hint(&issue)),
64 }
65 })
66 .collect()
67 }
68}
69
70fn issue_anchor(issue: &Issue) -> Option<u32> {
73 match issue {
74 Issue::DanglingSupersededBy { from, .. }
75 | Issue::DanglingSupersedes { from, .. }
76 | Issue::SupersededWithoutPointer { from }
77 | Issue::PointerWithoutSupersededStatus { from }
78 | Issue::AsymmetricLink { from, .. } => Some(*from),
79 Issue::Cycle(chain) => chain.first().copied(),
81 }
82}
83
84fn fix_hint(issue: &Issue) -> String {
85 match issue {
86 Issue::Cycle(_) => {
87 "break the supersede cycle — an ADR cannot transitively supersede itself".to_string()
88 }
89 Issue::DanglingSupersededBy { target, .. } => {
90 format!("`superseded-by` points at ADR-{target:04}, which doesn't exist; fix the id or create it")
91 }
92 Issue::DanglingSupersedes { target, .. } => {
93 format!("`supersedes` lists ADR-{target:04}, which doesn't exist; fix the id")
94 }
95 Issue::SupersededWithoutPointer { .. } => {
96 "status is `superseded` but `superseded-by` is missing; add the pointer".to_string()
97 }
98 Issue::PointerWithoutSupersededStatus { .. } => {
99 "has `superseded-by` but status isn't `superseded`; set status or drop the pointer"
100 .to_string()
101 }
102 Issue::AsymmetricLink { from, to } => {
103 format!(
104 "ADR-{from:04} and ADR-{to:04} disagree; make `supersedes`/`superseded-by` mutual"
105 )
106 }
107 }
108}
109
110fn index_path(root: &std::path::Path) -> PathBuf {
111 rel(&root.join("wiki/decisions/_index.md"), root)
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use std::fs;
118 use tempfile::TempDir;
119
120 fn write_adr(
122 tmp: &TempDir,
123 id: u32,
124 status: &str,
125 supersedes: &[u32],
126 superseded_by: Option<u32>,
127 ) {
128 let dir = tmp.path().join("wiki/decisions");
129 fs::create_dir_all(&dir).unwrap();
130 let mut body = String::from("---\n");
131 body.push_str(&format!("id: {id:04}\n"));
132 body.push_str(&format!("title: t{id}\n"));
133 body.push_str(&format!("status: {status}\n"));
134 body.push_str("date: 2026-01-01\n");
135 if !supersedes.is_empty() {
136 let s: Vec<String> = supersedes.iter().map(|i| format!("{i:04}")).collect();
137 body.push_str(&format!("supersedes: [{}]\n", s.join(", ")));
138 }
139 if let Some(by) = superseded_by {
140 body.push_str(&format!("superseded-by: {by:04}\n"));
141 }
142 body.push_str("---\n\nbody\n");
143 fs::write(dir.join(format!("{id:04}-x.md")), body).unwrap();
144 }
145
146 #[test]
147 fn clean_chain_produces_no_findings() {
148 let tmp = TempDir::new().unwrap();
149 write_adr(&tmp, 1, "superseded", &[], Some(2));
150 write_adr(&tmp, 2, "accepted", &[1], None);
151 let ctx = Context::new(tmp.path().to_path_buf());
152 assert!(AdrGraphClean.run(&ctx).is_empty());
153 }
154
155 #[test]
156 fn empty_decisions_dir_is_clean() {
157 let tmp = TempDir::new().unwrap();
158 fs::create_dir_all(tmp.path().join("wiki/decisions")).unwrap();
159 let ctx = Context::new(tmp.path().to_path_buf());
160 assert!(AdrGraphClean.run(&ctx).is_empty());
161 }
162
163 #[test]
164 fn adding_an_unrelated_adr_never_flags() {
165 let tmp = TempDir::new().unwrap();
168 for id in 1..=11 {
169 write_adr(&tmp, id, "accepted", &[], None);
170 }
171 let ctx = Context::new(tmp.path().to_path_buf());
172 assert!(AdrGraphClean.run(&ctx).is_empty());
173 }
174
175 #[test]
176 fn dangling_superseded_by_blocks_and_anchors_to_source() {
177 let tmp = TempDir::new().unwrap();
178 write_adr(&tmp, 1, "superseded", &[], Some(99));
179 let ctx = Context::new(tmp.path().to_path_buf());
180 let f = AdrGraphClean.run(&ctx);
181 assert_eq!(f.len(), 1);
182 assert_eq!(f[0].severity, Severity::Hard);
183 assert_eq!(f[0].kind, FindingKind::AdrGraphUnclean);
184 assert!(f[0].file.to_string_lossy().contains("0001"));
186 }
187
188 #[test]
189 fn supersede_cycle_blocks() {
190 let tmp = TempDir::new().unwrap();
191 write_adr(&tmp, 1, "superseded", &[2], Some(2));
192 write_adr(&tmp, 2, "superseded", &[1], Some(1));
193 let ctx = Context::new(tmp.path().to_path_buf());
194 let f = AdrGraphClean.run(&ctx);
195 assert!(f.iter().all(|x| x.severity == Severity::Hard));
196 assert!(f.iter().any(|x| x.claim.contains("cycle")));
197 }
198}