Skip to main content

koala_drift/checks/
adr_graph_clean.rs

1//! `adr.graph-clean` — the ADR supersede graph must be clean: no cycles,
2//! no dangling `supersedes` / `superseded-by` targets, no status/pointer
3//! mismatch, no asymmetric bidirectional links. This recomputes
4//! `koala_adr::validate` against the current `wiki/decisions/` on every run.
5//!
6//! This is the live drift equivalent of recording `koala-core adr validate`
7//! output as a reviewer artifact. That artifact's stdout embeds a running
8//! ADR count (`✓ N ADR(s) — graph clean.`), so it went TAMPERED on any PR
9//! that added an ADR — even though nobody touched the artifact (issue #23).
10//! Per ADR-0017's criterion ("does re-running this command change output as
11//! the repo evolves? → it's a current-state assertion, it belongs in drift,
12//! not a hash-frozen artifact"), graph-cleanliness is recomputed here.
13//! See ADR-0018.
14
15use 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        // A registry that won't even load (malformed frontmatter) is the
36        // job of `adr_frontmatter_valid` invariant / parser; here we simply
37        // have nothing to validate.
38        let Ok(reg) = Registry::load(ctx.root()) else {
39            return Vec::new();
40        };
41
42        validate(&reg)
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                    // No on-disk file (e.g. a dangling *target*): point at the
54                    // decisions index so the finding still resolves to a place.
55                    .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
70/// The ADR id whose own file is the natural home for the finding: the
71/// `from` side of every issue (the ADR carrying the bad pointer/status).
72fn 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        // The cycle's first node is as good an entry point as any.
80        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    /// Write one ADR file with the given frontmatter knobs.
121    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        // The whole point of issue #23: a clean graph stays clean no matter
166        // how many ADRs exist — count is never part of the signal.
167        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        // Anchored to ADR-0001's own file, not the index.
185        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}