use crate::check::{Check, Finding, FindingKind, Severity};
use crate::scan::rel;
use koala_adr::{validate, Issue, Registry};
use koala_core::invariant::Context;
use std::path::PathBuf;
pub struct AdrGraphClean;
impl Check for AdrGraphClean {
fn id(&self) -> &'static str {
"adr.graph-clean"
}
fn intent(&self) -> &'static str {
"The ADR supersede graph must stay clean — no cycles, dangling \
supersede targets, status/pointer mismatches, or asymmetric links. \
Recomputed live so adding an ADR never false-flags (issue #23)."
}
fn run(&self, ctx: &Context) -> Vec<Finding> {
let Ok(reg) = Registry::load(ctx.root()) else {
return Vec::new();
};
validate(®)
.into_iter()
.map(|issue| {
let anchor = issue_anchor(&issue);
let file = anchor
.and_then(|id| {
reg.entries()
.iter()
.find(|e| e.frontmatter.id == id)
.map(|e| e.relative.clone())
})
.unwrap_or_else(|| index_path(ctx.root()));
Finding {
check_id: self.id(),
file,
line: 1,
claim: issue.to_string(),
kind: FindingKind::AdrGraphUnclean,
severity: Severity::Hard,
fix_hint: Some(fix_hint(&issue)),
}
})
.collect()
}
}
fn issue_anchor(issue: &Issue) -> Option<u32> {
match issue {
Issue::DanglingSupersededBy { from, .. }
| Issue::DanglingSupersedes { from, .. }
| Issue::SupersededWithoutPointer { from }
| Issue::PointerWithoutSupersededStatus { from }
| Issue::AsymmetricLink { from, .. } => Some(*from),
Issue::Cycle(chain) => chain.first().copied(),
}
}
fn fix_hint(issue: &Issue) -> String {
match issue {
Issue::Cycle(_) => {
"break the supersede cycle — an ADR cannot transitively supersede itself".to_string()
}
Issue::DanglingSupersededBy { target, .. } => {
format!("`superseded-by` points at ADR-{target:04}, which doesn't exist; fix the id or create it")
}
Issue::DanglingSupersedes { target, .. } => {
format!("`supersedes` lists ADR-{target:04}, which doesn't exist; fix the id")
}
Issue::SupersededWithoutPointer { .. } => {
"status is `superseded` but `superseded-by` is missing; add the pointer".to_string()
}
Issue::PointerWithoutSupersededStatus { .. } => {
"has `superseded-by` but status isn't `superseded`; set status or drop the pointer"
.to_string()
}
Issue::AsymmetricLink { from, to } => {
format!(
"ADR-{from:04} and ADR-{to:04} disagree; make `supersedes`/`superseded-by` mutual"
)
}
}
}
fn index_path(root: &std::path::Path) -> PathBuf {
rel(&root.join("wiki/decisions/_index.md"), root)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_adr(
tmp: &TempDir,
id: u32,
status: &str,
supersedes: &[u32],
superseded_by: Option<u32>,
) {
let dir = tmp.path().join("wiki/decisions");
fs::create_dir_all(&dir).unwrap();
let mut body = String::from("---\n");
body.push_str(&format!("id: {id:04}\n"));
body.push_str(&format!("title: t{id}\n"));
body.push_str(&format!("status: {status}\n"));
body.push_str("date: 2026-01-01\n");
if !supersedes.is_empty() {
let s: Vec<String> = supersedes.iter().map(|i| format!("{i:04}")).collect();
body.push_str(&format!("supersedes: [{}]\n", s.join(", ")));
}
if let Some(by) = superseded_by {
body.push_str(&format!("superseded-by: {by:04}\n"));
}
body.push_str("---\n\nbody\n");
fs::write(dir.join(format!("{id:04}-x.md")), body).unwrap();
}
#[test]
fn clean_chain_produces_no_findings() {
let tmp = TempDir::new().unwrap();
write_adr(&tmp, 1, "superseded", &[], Some(2));
write_adr(&tmp, 2, "accepted", &[1], None);
let ctx = Context::new(tmp.path().to_path_buf());
assert!(AdrGraphClean.run(&ctx).is_empty());
}
#[test]
fn empty_decisions_dir_is_clean() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("wiki/decisions")).unwrap();
let ctx = Context::new(tmp.path().to_path_buf());
assert!(AdrGraphClean.run(&ctx).is_empty());
}
#[test]
fn adding_an_unrelated_adr_never_flags() {
let tmp = TempDir::new().unwrap();
for id in 1..=11 {
write_adr(&tmp, id, "accepted", &[], None);
}
let ctx = Context::new(tmp.path().to_path_buf());
assert!(AdrGraphClean.run(&ctx).is_empty());
}
#[test]
fn dangling_superseded_by_blocks_and_anchors_to_source() {
let tmp = TempDir::new().unwrap();
write_adr(&tmp, 1, "superseded", &[], Some(99));
let ctx = Context::new(tmp.path().to_path_buf());
let f = AdrGraphClean.run(&ctx);
assert_eq!(f.len(), 1);
assert_eq!(f[0].severity, Severity::Hard);
assert_eq!(f[0].kind, FindingKind::AdrGraphUnclean);
assert!(f[0].file.to_string_lossy().contains("0001"));
}
#[test]
fn supersede_cycle_blocks() {
let tmp = TempDir::new().unwrap();
write_adr(&tmp, 1, "superseded", &[2], Some(2));
write_adr(&tmp, 2, "superseded", &[1], Some(1));
let ctx = Context::new(tmp.path().to_path_buf());
let f = AdrGraphClean.run(&ctx);
assert!(f.iter().all(|x| x.severity == Severity::Hard));
assert!(f.iter().any(|x| x.claim.contains("cycle")));
}
}